diff --git a/CHANGELOG.md b/CHANGELOG.md
index ebf9ac10b..a9f331810 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,8 +4,13 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+### Added
+
+- handle launch error to report and export DB
+
### Changed
+- DB post-upgrade sanitization
- upgraded Flutter to stable v3.29.2
## [v1.12.6] - 2025-03-11
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ActivityResultStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ActivityResultStreamHandler.kt
index 7fcc607f7..1f01f2817 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ActivityResultStreamHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ActivityResultStreamHandler.kt
@@ -49,6 +49,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
"requestMediaFileAccess" -> ioScope.launch { requestMediaFileAccess() }
"createFile" -> ioScope.launch { createFile() }
"openFile" -> ioScope.launch { openFile() }
+ "copyFile" -> ioScope.launch { copyFile() }
"edit" -> edit()
"pickCollectionFilters" -> pickCollectionFilters()
else -> endOfStream()
@@ -181,6 +182,49 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
safeStartActivityForStorageAccessResult(intent, MainActivity.OPEN_FILE_REQUEST, ::onGranted, ::onDenied)
}
+ private suspend fun copyFile() {
+ val name = args["name"] as String?
+ val mimeType = args["mimeType"] as String?
+ val sourceUri = (args["sourceUri"] as String?)?.toUri()
+ if (name == null || mimeType == null || sourceUri == null) {
+ error("copyFile-args", "missing arguments", null)
+ return
+ }
+
+ fun onGranted(uri: Uri) {
+ ioScope.launch {
+ try {
+ StorageUtils.openInputStream(activity, sourceUri)?.use { input ->
+ // truncate is necessary when overwriting a longer file
+ activity.contentResolver.openOutputStream(uri, "wt")?.use { output ->
+ val buffer = ByteArray(BUFFER_SIZE)
+ var len: Int
+ while (input.read(buffer).also { len = it } != -1) {
+ output.write(buffer, 0, len)
+ }
+ }
+ }
+ success(true)
+ } catch (e: Exception) {
+ error("copyFile-write", "failed to copy file from sourceUri=$sourceUri to uri=$uri", e.message)
+ }
+ endOfStream()
+ }
+ }
+
+ fun onDenied() {
+ success(null)
+ endOfStream()
+ }
+
+ val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = mimeType
+ putExtra(Intent.EXTRA_TITLE, name)
+ }
+ safeStartActivityForStorageAccessResult(intent, MainActivity.CREATE_FILE_REQUEST, ::onGranted, ::onDenied)
+ }
+
private fun edit() {
val uri = args["uri"] as String?
val mimeType = args["mimeType"] as String? // optional
diff --git a/lib/model/db/db.dart b/lib/model/db/db.dart
index d48ad360e..7121361f2 100644
--- a/lib/model/db/db.dart
+++ b/lib/model/db/db.dart
@@ -12,6 +12,8 @@ import 'package:aves/model/viewer/video_playback.dart';
abstract class LocalMediaDb {
int get nextId;
+ Future get path;
+
Future init();
Future dbFileSize();
diff --git a/lib/model/db/db_extension.dart b/lib/model/db/db_extension.dart
new file mode 100644
index 000000000..385fff5e0
--- /dev/null
+++ b/lib/model/db/db_extension.dart
@@ -0,0 +1,15 @@
+import 'package:sqflite/sqflite.dart';
+
+extension ExtraDatabase on Database {
+ // check table existence
+ // proper way is to select from `sqlite_master` but this meta table may be missing on some devices
+ // so we rely on failure check instead
+ bool tableExists(String table) {
+ try {
+ query(table, limit: 1);
+ return true;
+ } catch (error) {
+ return false;
+ }
+ }
+}
diff --git a/lib/model/db/db_sqflite.dart b/lib/model/db/db_sqflite.dart
index 66bc79d6c..91da588e6 100644
--- a/lib/model/db/db_sqflite.dart
+++ b/lib/model/db/db_sqflite.dart
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:aves/model/covers.dart';
import 'package:aves/model/db/db.dart';
+import 'package:aves/model/db/db_sqflite_schema.dart';
import 'package:aves/model/db/db_sqflite_upgrade.dart';
import 'package:aves/model/dynamic_albums.dart';
import 'package:aves/model/entry/entry.dart';
@@ -20,18 +21,19 @@ import 'package:sqflite/sqflite.dart';
class SqfliteLocalMediaDb implements LocalMediaDb {
late Database _db;
+ @override
Future get path async => pContext.join(await getDatabasesPath(), 'metadata.db');
- static const entryTable = 'entry';
- static const dateTakenTable = 'dateTaken';
- static const metadataTable = 'metadata';
- static const addressTable = 'address';
- static const favouriteTable = 'favourites';
- static const coverTable = 'covers';
- static const dynamicAlbumTable = 'dynamicAlbums';
- static const vaultTable = 'vaults';
- static const trashTable = 'trash';
- static const videoPlaybackTable = 'videoPlayback';
+ static const entryTable = SqfliteLocalMediaDbSchema.entryTable;
+ static const dateTakenTable = SqfliteLocalMediaDbSchema.dateTakenTable;
+ static const metadataTable = SqfliteLocalMediaDbSchema.metadataTable;
+ static const addressTable = SqfliteLocalMediaDbSchema.addressTable;
+ static const favouriteTable = SqfliteLocalMediaDbSchema.favouriteTable;
+ static const coverTable = SqfliteLocalMediaDbSchema.coverTable;
+ static const dynamicAlbumTable = SqfliteLocalMediaDbSchema.dynamicAlbumTable;
+ static const vaultTable = SqfliteLocalMediaDbSchema.vaultTable;
+ static const trashTable = SqfliteLocalMediaDbSchema.trashTable;
+ static const videoPlaybackTable = SqfliteLocalMediaDbSchema.videoPlaybackTable;
static const _entryInsertSliceMaxCount = 10000; // number of entries
static const _queryCursorBufferSize = 1000; // number of rows
@@ -44,78 +46,7 @@ class SqfliteLocalMediaDb implements LocalMediaDb {
Future init() async {
_db = await openDatabase(
await path,
- onCreate: (db, version) async {
- await db.execute('CREATE TABLE $entryTable('
- 'id INTEGER PRIMARY KEY'
- ', contentId INTEGER'
- ', uri TEXT'
- ', path TEXT'
- ', sourceMimeType TEXT'
- ', width INTEGER'
- ', height INTEGER'
- ', sourceRotationDegrees INTEGER'
- ', sizeBytes INTEGER'
- ', title TEXT'
- ', dateAddedSecs INTEGER DEFAULT (strftime(\'%s\',\'now\'))'
- ', dateModifiedMillis INTEGER'
- ', sourceDateTakenMillis INTEGER'
- ', durationMillis INTEGER'
- ', trashed INTEGER DEFAULT 0'
- ', origin INTEGER DEFAULT 0'
- ')');
- await db.execute('CREATE TABLE $dateTakenTable('
- 'id INTEGER PRIMARY KEY'
- ', dateMillis INTEGER'
- ')');
- await db.execute('CREATE TABLE $metadataTable('
- 'id INTEGER PRIMARY KEY'
- ', mimeType TEXT'
- ', dateMillis INTEGER'
- ', flags INTEGER'
- ', rotationDegrees INTEGER'
- ', xmpSubjects TEXT'
- ', xmpTitle TEXT'
- ', latitude REAL'
- ', longitude REAL'
- ', rating INTEGER'
- ')');
- await db.execute('CREATE TABLE $addressTable('
- 'id INTEGER PRIMARY KEY'
- ', addressLine TEXT'
- ', countryCode TEXT'
- ', countryName TEXT'
- ', adminArea TEXT'
- ', locality TEXT'
- ')');
- await db.execute('CREATE TABLE $favouriteTable('
- 'id INTEGER PRIMARY KEY'
- ')');
- await db.execute('CREATE TABLE $coverTable('
- 'filter TEXT PRIMARY KEY'
- ', entryId INTEGER'
- ', packageName TEXT'
- ', color TEXT'
- ')');
- await db.execute('CREATE TABLE $dynamicAlbumTable('
- 'name TEXT PRIMARY KEY'
- ', filter TEXT'
- ')');
- await db.execute('CREATE TABLE $vaultTable('
- 'name TEXT PRIMARY KEY'
- ', autoLock INTEGER'
- ', useBin INTEGER'
- ', lockType TEXT'
- ')');
- await db.execute('CREATE TABLE $trashTable('
- 'id INTEGER PRIMARY KEY'
- ', path TEXT'
- ', dateMillis INTEGER'
- ')');
- await db.execute('CREATE TABLE $videoPlaybackTable('
- 'id INTEGER PRIMARY KEY'
- ', resumeTimeMillis INTEGER'
- ')');
- },
+ onCreate: (db, version) => SqfliteLocalMediaDbSchema.createLatestVersion(db),
onUpgrade: LocalMediaDbUpgrader.upgradeDb,
version: 15,
);
diff --git a/lib/model/db/db_sqflite_schema.dart b/lib/model/db/db_sqflite_schema.dart
new file mode 100644
index 000000000..1d1d81054
--- /dev/null
+++ b/lib/model/db/db_sqflite_schema.dart
@@ -0,0 +1,118 @@
+import 'package:sqflite/sqflite.dart';
+
+class SqfliteLocalMediaDbSchema {
+ static const entryTable = 'entry';
+ static const dateTakenTable = 'dateTaken';
+ static const metadataTable = 'metadata';
+ static const addressTable = 'address';
+ static const favouriteTable = 'favourites';
+ static const coverTable = 'covers';
+ static const dynamicAlbumTable = 'dynamicAlbums';
+ static const vaultTable = 'vaults';
+ static const trashTable = 'trash';
+ static const videoPlaybackTable = 'videoPlayback';
+
+ static const allTables = [
+ entryTable,
+ dateTakenTable,
+ metadataTable,
+ addressTable,
+ favouriteTable,
+ coverTable,
+ dynamicAlbumTable,
+ vaultTable,
+ trashTable,
+ videoPlaybackTable,
+ ];
+
+ static Future createLatestVersion(Database db) async {
+ await Future.forEach(allTables, (table) => createTable(db, table));
+ }
+
+ static Future createTable(Database db, String table) {
+ switch (table) {
+ case entryTable:
+ return db.execute('CREATE TABLE $entryTable('
+ 'id INTEGER PRIMARY KEY'
+ ', contentId INTEGER'
+ ', uri TEXT'
+ ', path TEXT'
+ ', sourceMimeType TEXT'
+ ', width INTEGER'
+ ', height INTEGER'
+ ', sourceRotationDegrees INTEGER'
+ ', sizeBytes INTEGER'
+ ', title TEXT'
+ ', dateAddedSecs INTEGER DEFAULT (strftime(\'%s\',\'now\'))'
+ ', dateModifiedMillis INTEGER'
+ ', sourceDateTakenMillis INTEGER'
+ ', durationMillis INTEGER'
+ ', trashed INTEGER DEFAULT 0'
+ ', origin INTEGER DEFAULT 0'
+ ')');
+ case dateTakenTable:
+ return db.execute('CREATE TABLE $dateTakenTable('
+ 'id INTEGER PRIMARY KEY'
+ ', dateMillis INTEGER'
+ ')');
+ case metadataTable:
+ return db.execute('CREATE TABLE $metadataTable('
+ 'id INTEGER PRIMARY KEY'
+ ', mimeType TEXT'
+ ', dateMillis INTEGER'
+ ', flags INTEGER'
+ ', rotationDegrees INTEGER'
+ ', xmpSubjects TEXT'
+ ', xmpTitle TEXT'
+ ', latitude REAL'
+ ', longitude REAL'
+ ', rating INTEGER'
+ ')');
+ case addressTable:
+ return db.execute('CREATE TABLE $addressTable('
+ 'id INTEGER PRIMARY KEY'
+ ', addressLine TEXT'
+ ', countryCode TEXT'
+ ', countryName TEXT'
+ ', adminArea TEXT'
+ ', locality TEXT'
+ ')');
+ case favouriteTable:
+ return db.execute('CREATE TABLE $favouriteTable('
+ 'id INTEGER PRIMARY KEY'
+ ')');
+ case coverTable:
+ return db.execute('CREATE TABLE $coverTable('
+ 'filter TEXT PRIMARY KEY'
+ ', entryId INTEGER'
+ ', packageName TEXT'
+ ', color TEXT'
+ ')');
+ case dynamicAlbumTable:
+ return db.execute('CREATE TABLE $dynamicAlbumTable('
+ 'name TEXT PRIMARY KEY'
+ ', filter TEXT'
+ ')');
+ case vaultTable:
+ return db.execute('CREATE TABLE $vaultTable('
+ 'name TEXT PRIMARY KEY'
+ ', autoLock INTEGER'
+ ', useBin INTEGER'
+ ', lockType TEXT'
+ ')');
+ case trashTable:
+ return db.execute('CREATE TABLE $trashTable('
+ 'id INTEGER PRIMARY KEY'
+ ', path TEXT'
+ ', dateMillis INTEGER'
+ ')');
+ case videoPlaybackTable:
+ return db.execute('CREATE TABLE $videoPlaybackTable('
+ 'id INTEGER PRIMARY KEY'
+ ', resumeTimeMillis INTEGER'
+ ')');
+ default:
+ throw Exception('unknown table=$table');
+ }
+ }
+}
diff --git a/lib/model/db/db_sqflite_upgrade.dart b/lib/model/db/db_sqflite_upgrade.dart
index 3d8923489..1442aa13c 100644
--- a/lib/model/db/db_sqflite_upgrade.dart
+++ b/lib/model/db/db_sqflite_upgrade.dart
@@ -1,22 +1,23 @@
import 'dart:ui';
import 'package:aves/model/covers.dart';
-import 'package:aves/model/db/db_sqflite.dart';
+import 'package:aves/model/db/db_extension.dart';
+import 'package:aves/model/db/db_sqflite_schema.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
class LocalMediaDbUpgrader {
- static const entryTable = SqfliteLocalMediaDb.entryTable;
- static const dateTakenTable = SqfliteLocalMediaDb.dateTakenTable;
- static const metadataTable = SqfliteLocalMediaDb.metadataTable;
- static const addressTable = SqfliteLocalMediaDb.addressTable;
- static const favouriteTable = SqfliteLocalMediaDb.favouriteTable;
- static const coverTable = SqfliteLocalMediaDb.coverTable;
- static const dynamicAlbumTable = SqfliteLocalMediaDb.dynamicAlbumTable;
- static const vaultTable = SqfliteLocalMediaDb.vaultTable;
- static const trashTable = SqfliteLocalMediaDb.trashTable;
- static const videoPlaybackTable = SqfliteLocalMediaDb.videoPlaybackTable;
+ static const entryTable = SqfliteLocalMediaDbSchema.entryTable;
+ static const dateTakenTable = SqfliteLocalMediaDbSchema.dateTakenTable;
+ static const metadataTable = SqfliteLocalMediaDbSchema.metadataTable;
+ static const addressTable = SqfliteLocalMediaDbSchema.addressTable;
+ static const favouriteTable = SqfliteLocalMediaDbSchema.favouriteTable;
+ static const coverTable = SqfliteLocalMediaDbSchema.coverTable;
+ static const dynamicAlbumTable = SqfliteLocalMediaDbSchema.dynamicAlbumTable;
+ static const vaultTable = SqfliteLocalMediaDbSchema.vaultTable;
+ static const trashTable = SqfliteLocalMediaDbSchema.trashTable;
+ static const videoPlaybackTable = SqfliteLocalMediaDbSchema.videoPlaybackTable;
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported
// on SQLite <3.25.0, bundled on older Android devices
@@ -55,55 +56,68 @@ class LocalMediaDbUpgrader {
}
oldVersion++;
}
+ await _sanitize(db);
+ }
+
+ static Future _sanitize(Database db) async {
+ // ensure all tables exist
+ await Future.forEach(SqfliteLocalMediaDbSchema.allTables, (table) async {
+ if (!db.tableExists(table)) {
+ await SqfliteLocalMediaDbSchema.createTable(db, table);
+ }
+ });
+
+ // remove rows referencing future entry IDs
+ final maxIdRows = await db.rawQuery('SELECT MAX(id) AS maxId FROM $entryTable');
+ final lastId = (maxIdRows.firstOrNull?['maxId'] as int?) ?? 0;
+ await db.delete(favouriteTable, where: 'id > ?', whereArgs: [lastId]);
+ await db.delete(coverTable, where: 'entryId > ?', whereArgs: [lastId]);
}
static Future _upgradeFrom1(Database db) async {
debugPrint('upgrading DB from v1');
+
// rename column 'orientationDegrees' to 'sourceRotationDegrees'
- await db.transaction((txn) async {
- const newEntryTable = '${entryTable}TEMP';
- await db.execute('CREATE TABLE $newEntryTable('
- 'contentId INTEGER PRIMARY KEY'
- ', uri TEXT'
- ', path TEXT'
- ', sourceMimeType TEXT'
- ', width INTEGER'
- ', height INTEGER'
- ', sourceRotationDegrees INTEGER'
- ', sizeBytes INTEGER'
- ', title TEXT'
- ', dateModifiedSecs INTEGER'
- ', sourceDateTakenMillis INTEGER'
- ', durationMillis INTEGER'
- ')');
- await db.rawInsert('INSERT INTO $newEntryTable(contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)'
- ' SELECT contentId,uri,path,sourceMimeType,width,height,orientationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis'
- ' FROM $entryTable;');
- await db.execute('DROP TABLE $entryTable;');
- await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;');
- });
+ const newEntryTable = '${entryTable}TEMP';
+ await db.execute('CREATE TABLE $newEntryTable ('
+ 'contentId INTEGER PRIMARY KEY'
+ ', uri TEXT'
+ ', path TEXT'
+ ', sourceMimeType TEXT'
+ ', width INTEGER'
+ ', height INTEGER'
+ ', sourceRotationDegrees INTEGER'
+ ', sizeBytes INTEGER'
+ ', title TEXT'
+ ', dateModifiedSecs INTEGER'
+ ', sourceDateTakenMillis INTEGER'
+ ', durationMillis INTEGER'
+ ')');
+ await db.rawInsert('INSERT INTO $newEntryTable (contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)'
+ ' SELECT contentId,uri,path,sourceMimeType,width,height,orientationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis'
+ ' FROM $entryTable;');
+ await db.execute('DROP TABLE $entryTable;');
+ await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;');
// rename column 'videoRotation' to 'rotationDegrees'
- await db.transaction((txn) async {
- const newMetadataTable = '${metadataTable}TEMP';
- await db.execute('CREATE TABLE $newMetadataTable('
- 'contentId INTEGER PRIMARY KEY'
- ', mimeType TEXT'
- ', dateMillis INTEGER'
- ', isAnimated INTEGER'
- ', rotationDegrees INTEGER'
- ', xmpSubjects TEXT'
- ', xmpTitleDescription TEXT'
- ', latitude REAL'
- ', longitude REAL'
- ')');
- await db.rawInsert('INSERT INTO $newMetadataTable(contentId,mimeType,dateMillis,isAnimated,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)'
- ' SELECT contentId,mimeType,dateMillis,isAnimated,videoRotation,xmpSubjects,xmpTitleDescription,latitude,longitude'
- ' FROM $metadataTable;');
- await db.rawInsert('UPDATE $newMetadataTable SET rotationDegrees = NULL WHERE rotationDegrees = 0;');
- await db.execute('DROP TABLE $metadataTable;');
- await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
- });
+ const newMetadataTable = '${metadataTable}TEMP';
+ await db.execute('CREATE TABLE $newMetadataTable ('
+ 'contentId INTEGER PRIMARY KEY'
+ ', mimeType TEXT'
+ ', dateMillis INTEGER'
+ ', isAnimated INTEGER'
+ ', rotationDegrees INTEGER'
+ ', xmpSubjects TEXT'
+ ', xmpTitleDescription TEXT'
+ ', latitude REAL'
+ ', longitude REAL'
+ ')');
+ await db.rawInsert('INSERT INTO $newMetadataTable (contentId,mimeType,dateMillis,isAnimated,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)'
+ ' SELECT contentId,mimeType,dateMillis,isAnimated,videoRotation,xmpSubjects,xmpTitleDescription,latitude,longitude'
+ ' FROM $metadataTable;');
+ await db.rawInsert('UPDATE $newMetadataTable SET rotationDegrees = NULL WHERE rotationDegrees = 0;');
+ await db.execute('DROP TABLE $metadataTable;');
+ await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
// new column 'isFlipped'
await db.execute('ALTER TABLE $metadataTable ADD COLUMN isFlipped INTEGER;');
@@ -111,31 +125,30 @@ class LocalMediaDbUpgrader {
static Future _upgradeFrom2(Database db) async {
debugPrint('upgrading DB from v2');
+
// merge columns 'isAnimated' and 'isFlipped' into 'flags'
- await db.transaction((txn) async {
- const newMetadataTable = '${metadataTable}TEMP';
- await db.execute('CREATE TABLE $newMetadataTable('
- 'contentId INTEGER PRIMARY KEY'
- ', mimeType TEXT'
- ', dateMillis INTEGER'
- ', flags INTEGER'
- ', rotationDegrees INTEGER'
- ', xmpSubjects TEXT'
- ', xmpTitleDescription TEXT'
- ', latitude REAL'
- ', longitude REAL'
- ')');
- await db.rawInsert('INSERT INTO $newMetadataTable(contentId,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)'
- ' SELECT contentId,mimeType,dateMillis,ifnull(isAnimated,0)+ifnull(isFlipped,0)*2,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude'
- ' FROM $metadataTable;');
- await db.execute('DROP TABLE $metadataTable;');
- await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
- });
+ const newMetadataTable = '${metadataTable}TEMP';
+ await db.execute('CREATE TABLE $newMetadataTable ('
+ 'contentId INTEGER PRIMARY KEY'
+ ', mimeType TEXT'
+ ', dateMillis INTEGER'
+ ', flags INTEGER'
+ ', rotationDegrees INTEGER'
+ ', xmpSubjects TEXT'
+ ', xmpTitleDescription TEXT'
+ ', latitude REAL'
+ ', longitude REAL'
+ ')');
+ await db.rawInsert('INSERT INTO $newMetadataTable (contentId,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)'
+ ' SELECT contentId,mimeType,dateMillis,ifnull(isAnimated,0)+ifnull(isFlipped,0)*2,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude'
+ ' FROM $metadataTable;');
+ await db.execute('DROP TABLE $metadataTable;');
+ await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
}
static Future _upgradeFrom3(Database db) async {
debugPrint('upgrading DB from v3');
- await db.execute('CREATE TABLE $coverTable('
+ await db.execute('CREATE TABLE $coverTable ('
'filter TEXT PRIMARY KEY'
', contentId INTEGER'
')');
@@ -143,7 +156,7 @@ class LocalMediaDbUpgrader {
static Future _upgradeFrom4(Database db) async {
debugPrint('upgrading DB from v4');
- await db.execute('CREATE TABLE $videoPlaybackTable('
+ await db.execute('CREATE TABLE $videoPlaybackTable ('
'contentId INTEGER PRIMARY KEY'
', resumeTimeMillis INTEGER'
')');
@@ -160,7 +173,7 @@ class LocalMediaDbUpgrader {
// new column `trashed`
await db.transaction((txn) async {
const newEntryTable = '${entryTable}TEMP';
- await db.execute('CREATE TABLE $newEntryTable('
+ await db.execute('CREATE TABLE $newEntryTable ('
'id INTEGER PRIMARY KEY'
', contentId INTEGER'
', uri TEXT'
@@ -176,7 +189,7 @@ class LocalMediaDbUpgrader {
', durationMillis INTEGER'
', trashed INTEGER DEFAULT 0'
')');
- await db.rawInsert('INSERT INTO $newEntryTable(id,contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)'
+ await db.rawInsert('INSERT INTO $newEntryTable (id,contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)'
' SELECT contentId,contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis'
' FROM $entryTable;');
await db.execute('DROP TABLE $entryTable;');
@@ -186,11 +199,11 @@ class LocalMediaDbUpgrader {
// rename column `contentId` to `id`
await db.transaction((txn) async {
const newDateTakenTable = '${dateTakenTable}TEMP';
- await db.execute('CREATE TABLE $newDateTakenTable('
+ await db.execute('CREATE TABLE $newDateTakenTable ('
'id INTEGER PRIMARY KEY'
', dateMillis INTEGER'
')');
- await db.rawInsert('INSERT INTO $newDateTakenTable(id,dateMillis)'
+ await db.rawInsert('INSERT INTO $newDateTakenTable (id,dateMillis)'
' SELECT contentId,dateMillis'
' FROM $dateTakenTable;');
await db.execute('DROP TABLE $dateTakenTable;');
@@ -200,7 +213,7 @@ class LocalMediaDbUpgrader {
// rename column `contentId` to `id`
await db.transaction((txn) async {
const newMetadataTable = '${metadataTable}TEMP';
- await db.execute('CREATE TABLE $newMetadataTable('
+ await db.execute('CREATE TABLE $newMetadataTable ('
'id INTEGER PRIMARY KEY'
', mimeType TEXT'
', dateMillis INTEGER'
@@ -212,7 +225,7 @@ class LocalMediaDbUpgrader {
', longitude REAL'
', rating INTEGER'
')');
- await db.rawInsert('INSERT INTO $newMetadataTable(id,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude,rating)'
+ await db.rawInsert('INSERT INTO $newMetadataTable (id,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude,rating)'
' SELECT contentId,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude,rating'
' FROM $metadataTable;');
await db.execute('DROP TABLE $metadataTable;');
@@ -222,7 +235,7 @@ class LocalMediaDbUpgrader {
// rename column `contentId` to `id`
await db.transaction((txn) async {
const newAddressTable = '${addressTable}TEMP';
- await db.execute('CREATE TABLE $newAddressTable('
+ await db.execute('CREATE TABLE $newAddressTable ('
'id INTEGER PRIMARY KEY'
', addressLine TEXT'
', countryCode TEXT'
@@ -230,7 +243,7 @@ class LocalMediaDbUpgrader {
', adminArea TEXT'
', locality TEXT'
')');
- await db.rawInsert('INSERT INTO $newAddressTable(id,addressLine,countryCode,countryName,adminArea,locality)'
+ await db.rawInsert('INSERT INTO $newAddressTable (id,addressLine,countryCode,countryName,adminArea,locality)'
' SELECT contentId,addressLine,countryCode,countryName,adminArea,locality'
' FROM $addressTable;');
await db.execute('DROP TABLE $addressTable;');
@@ -240,11 +253,11 @@ class LocalMediaDbUpgrader {
// rename column `contentId` to `id`
await db.transaction((txn) async {
const newVideoPlaybackTable = '${videoPlaybackTable}TEMP';
- await db.execute('CREATE TABLE $newVideoPlaybackTable('
+ await db.execute('CREATE TABLE $newVideoPlaybackTable ('
'id INTEGER PRIMARY KEY'
', resumeTimeMillis INTEGER'
')');
- await db.rawInsert('INSERT INTO $newVideoPlaybackTable(id,resumeTimeMillis)'
+ await db.rawInsert('INSERT INTO $newVideoPlaybackTable (id,resumeTimeMillis)'
' SELECT contentId,resumeTimeMillis'
' FROM $videoPlaybackTable;');
await db.execute('DROP TABLE $videoPlaybackTable;');
@@ -255,10 +268,10 @@ class LocalMediaDbUpgrader {
// remove column `path`
await db.transaction((txn) async {
const newFavouriteTable = '${favouriteTable}TEMP';
- await db.execute('CREATE TABLE $newFavouriteTable('
+ await db.execute('CREATE TABLE $newFavouriteTable ('
'id INTEGER PRIMARY KEY'
')');
- await db.rawInsert('INSERT INTO $newFavouriteTable(id)'
+ await db.rawInsert('INSERT INTO $newFavouriteTable (id)'
' SELECT contentId'
' FROM $favouriteTable;');
await db.execute('DROP TABLE $favouriteTable;');
@@ -268,11 +281,11 @@ class LocalMediaDbUpgrader {
// rename column `contentId` to `entryId`
await db.transaction((txn) async {
const newCoverTable = '${coverTable}TEMP';
- await db.execute('CREATE TABLE $newCoverTable('
+ await db.execute('CREATE TABLE $newCoverTable ('
'filter TEXT PRIMARY KEY'
', entryId INTEGER'
')');
- await db.rawInsert('INSERT INTO $newCoverTable(filter,entryId)'
+ await db.rawInsert('INSERT INTO $newCoverTable (filter,entryId)'
' SELECT filter,contentId'
' FROM $coverTable;');
await db.execute('DROP TABLE $coverTable;');
@@ -280,7 +293,7 @@ class LocalMediaDbUpgrader {
});
// new table
- await db.execute('CREATE TABLE $trashTable('
+ await db.execute('CREATE TABLE $trashTable ('
'id INTEGER PRIMARY KEY'
', path TEXT'
', dateMillis INTEGER'
@@ -299,7 +312,7 @@ class LocalMediaDbUpgrader {
// new column `dateAddedSecs`
await db.transaction((txn) async {
const newEntryTable = '${entryTable}TEMP';
- await db.execute('CREATE TABLE $newEntryTable('
+ await db.execute('CREATE TABLE $newEntryTable ('
'id INTEGER PRIMARY KEY'
', contentId INTEGER'
', uri TEXT'
@@ -316,7 +329,7 @@ class LocalMediaDbUpgrader {
', durationMillis INTEGER'
', trashed INTEGER DEFAULT 0'
')');
- await db.rawInsert('INSERT INTO $newEntryTable(id,contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis,trashed)'
+ await db.rawInsert('INSERT INTO $newEntryTable (id,contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis,trashed)'
' SELECT id,contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis,trashed'
' FROM $entryTable;');
await db.execute('DROP TABLE $entryTable;');
@@ -326,7 +339,7 @@ class LocalMediaDbUpgrader {
// rename column `xmpTitleDescription` to `xmpTitle`
await db.transaction((txn) async {
const newMetadataTable = '${metadataTable}TEMP';
- await db.execute('CREATE TABLE $newMetadataTable('
+ await db.execute('CREATE TABLE $newMetadataTable ('
'id INTEGER PRIMARY KEY'
', mimeType TEXT'
', dateMillis INTEGER'
@@ -338,7 +351,7 @@ class LocalMediaDbUpgrader {
', longitude REAL'
', rating INTEGER'
')');
- await db.rawInsert('INSERT INTO $newMetadataTable(id,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitle,latitude,longitude,rating)'
+ await db.rawInsert('INSERT INTO $newMetadataTable (id,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitle,latitude,longitude,rating)'
' SELECT id,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude,rating'
' FROM $metadataTable;');
await db.execute('DROP TABLE $metadataTable;');
@@ -383,7 +396,7 @@ class LocalMediaDbUpgrader {
await db.execute('ALTER TABLE $entryTable ADD COLUMN origin INTEGER DEFAULT 0;');
- await db.execute('CREATE TABLE $vaultTable('
+ await db.execute('CREATE TABLE $vaultTable ('
'name TEXT PRIMARY KEY'
', autoLock INTEGER'
', useBin INTEGER'
@@ -394,7 +407,7 @@ class LocalMediaDbUpgrader {
static Future _upgradeFrom11(Database db) async {
debugPrint('upgrading DB from v11');
- await db.execute('CREATE TABLE $dynamicAlbumTable('
+ await db.execute('CREATE TABLE $dynamicAlbumTable ('
'name TEXT PRIMARY KEY'
', filter TEXT'
')');
@@ -423,40 +436,38 @@ class LocalMediaDbUpgrader {
}
// convert `color` column type from value number to JSON string
- await db.transaction((txn) async {
- const newCoverTable = '${coverTable}TEMP';
- await db.execute('CREATE TABLE $newCoverTable('
- 'filter TEXT PRIMARY KEY'
- ', entryId INTEGER'
- ', packageName TEXT'
- ', color TEXT'
- ')');
+ const newCoverTable = '${coverTable}TEMP';
+ await db.execute('CREATE TABLE $newCoverTable ('
+ 'filter TEXT PRIMARY KEY'
+ ', entryId INTEGER'
+ ', packageName TEXT'
+ ', color TEXT'
+ ')');
- // insert covers with `string` color value
- if (rows.isNotEmpty) {
- final batch = db.batch();
- rows.forEach((row) {
- batch.insert(
- newCoverTable,
- row.toMap(),
- conflictAlgorithm: ConflictAlgorithm.replace,
- );
- });
- await batch.commit(noResult: true);
- }
+ // insert covers with `string` color value
+ if (rows.isNotEmpty) {
+ final batch = db.batch();
+ rows.forEach((row) {
+ batch.insert(
+ newCoverTable,
+ row.toMap(),
+ conflictAlgorithm: ConflictAlgorithm.replace,
+ );
+ });
+ await batch.commit(noResult: true);
+ }
- await db.execute('DROP TABLE $coverTable;');
- await db.execute('ALTER TABLE $newCoverTable RENAME TO $coverTable;');
- });
+ await db.execute('DROP TABLE $coverTable;');
+ await db.execute('ALTER TABLE $newCoverTable RENAME TO $coverTable;');
}
static Future _upgradeFrom13(Database db) async {
debugPrint('upgrading DB from v13');
- // rename column 'dateModifiedSecs' to 'dateModifiedMillis'
- await db.transaction((txn) async {
+ if (db.tableExists(entryTable)) {
+ // rename column 'dateModifiedSecs' to 'dateModifiedMillis'
const newEntryTable = '${entryTable}TEMP';
- await db.execute('CREATE TABLE $newEntryTable('
+ await db.execute('CREATE TABLE $newEntryTable ('
'id INTEGER PRIMARY KEY'
', contentId INTEGER'
', uri TEXT'
@@ -474,30 +485,24 @@ class LocalMediaDbUpgrader {
', trashed INTEGER DEFAULT 0'
', origin INTEGER DEFAULT 0'
')');
- await db.rawInsert('INSERT INTO $newEntryTable(id,contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateAddedSecs,dateModifiedMillis,sourceDateTakenMillis,durationMillis,trashed,origin)'
+ await db.rawInsert('INSERT INTO $newEntryTable (id,contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateAddedSecs,dateModifiedMillis,sourceDateTakenMillis,durationMillis,trashed,origin)'
' SELECT id,contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateAddedSecs,dateModifiedSecs*1000,sourceDateTakenMillis,durationMillis,trashed,origin'
' FROM $entryTable;');
await db.execute('DROP TABLE $entryTable;');
await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;');
- });
+ }
}
static Future _upgradeFrom14(Database db) async {
debugPrint('upgrading DB from v14');
- // no schema changes, but v1.12.4 may have corrupted the DB, so we sanitize it
-
- // clear rebuildable tables
- await db.delete(dateTakenTable, where: '1');
- await db.delete(metadataTable, where: '1');
- await db.delete(addressTable, where: '1');
- await db.delete(trashTable, where: '1');
- await db.delete(videoPlaybackTable, where: '1');
-
- // remove rows referencing future entry IDs
- final maxIdRows = await db.rawQuery('SELECT MAX(id) AS maxId FROM $entryTable');
- final lastId = (maxIdRows.firstOrNull?['maxId'] as int?) ?? 0;
- await db.delete(favouriteTable, where: 'id > ?', whereArgs: [lastId]);
- await db.delete(coverTable, where: 'entryId > ?', whereArgs: [lastId]);
+ // no schema changes, but v1.12.4 may have corrupted the DB,
+ // so we clear rebuildable tables
+ final tables = [dateTakenTable, metadataTable, addressTable, trashTable, videoPlaybackTable];
+ await Future.forEach(tables, (table) async {
+ if (db.tableExists(table)) {
+ await db.delete(table, where: '1');
+ }
+ });
}
}
diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart
index ee8269c11..7ff064ea6 100644
--- a/lib/ref/mime_types.dart
+++ b/lib/ref/mime_types.dart
@@ -75,6 +75,7 @@ class MimeTypes {
static const json = 'application/json';
static const plainText = 'text/plain';
+ static const sqlite3 = 'application/vnd.sqlite3';
// JB2, JPC, JPX?
static const octetStream = 'application/octet-stream';
diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart
index 62589161b..3f9fc6a64 100644
--- a/lib/services/storage_service.dart
+++ b/lib/services/storage_service.dart
@@ -53,6 +53,9 @@ abstract class StorageService {
Future createFile(String name, String mimeType, Uint8List bytes);
Future openFile([String? mimeType]);
+
+ // return whether operation succeeded (`null` if user cancelled)
+ Future copyFile(String name, String mimeType, String sourceUri);
}
class PlatformStorageService implements StorageService {
@@ -369,4 +372,29 @@ class PlatformStorageService implements StorageService {
}
return Uint8List(0);
}
+
+ @override
+ Future copyFile(String name, String mimeType, String sourceUri) async {
+ try {
+ final opCompleter = Completer();
+ _stream.receiveBroadcastStream({
+ 'op': 'copyFile',
+ 'name': name,
+ 'mimeType': mimeType,
+ 'sourceUri': sourceUri,
+ }).listen(
+ (data) => opCompleter.complete(data as bool?),
+ onError: opCompleter.completeError,
+ onDone: () {
+ if (!opCompleter.isCompleted) opCompleter.complete(false);
+ },
+ cancelOnError: true,
+ );
+ // `await` here, so that `completeError` will be caught below
+ return await opCompleter.future;
+ } on PlatformException catch (e, stack) {
+ await reportService.recordError(e, stack);
+ }
+ return false;
+ }
}
diff --git a/lib/widgets/about/bug_report.dart b/lib/widgets/about/bug_report.dart
index a1b7c3333..694ef0296 100644
--- a/lib/widgets/about/bug_report.dart
+++ b/lib/widgets/about/bug_report.dart
@@ -36,21 +36,11 @@ class BugReport extends StatefulWidget {
State createState() => _BugReportState();
}
-class _BugReportState extends State with FeedbackMixin {
- late Future _infoLoader;
+class _BugReportState extends State {
bool _showInstructions = false;
- static const bugReportUrl = '${AppReference.avesGithub}/issues/new?labels=type%3Abug&template=bug_report.md';
-
- @override
- void initState() {
- super.initState();
- _infoLoader = _getInfo(context);
- }
-
@override
Widget build(BuildContext context) {
- final l10n = context.l10n;
final animationDuration = context.select((v) => v.expansionTileAnimation);
return ExpansionPanelList(
expansionCallback: (index, isExpanded) {
@@ -66,53 +56,10 @@ class _BugReportState extends State with FeedbackMixin {
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: AlignmentDirectional.centerStart,
- child: Text(l10n.aboutBugSectionTitle, style: AStyles.knownTitleText),
- ),
- ),
- body: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- _buildStep(1, l10n.aboutBugSaveLogInstruction, l10n.saveTooltip, _saveLogs),
- _buildStep(2, l10n.aboutBugCopyInfoInstruction, l10n.aboutBugCopyInfoButton, _copySystemInfo),
- FutureBuilder(
- future: _infoLoader,
- builder: (context, snapshot) {
- final info = snapshot.data;
- if (info == null) return const SizedBox();
-
- return Container(
- decoration: BoxDecoration(
- color: Themes.secondLayerColor(context),
- border: Border.all(
- color: Theme.of(context).dividerColor,
- width: AvesBorder.curvedBorderWidth(context),
- ),
- borderRadius: const BorderRadius.all(Radius.circular(16)),
- ),
- constraints: const BoxConstraints(maxHeight: 100),
- margin: const EdgeInsets.symmetric(vertical: 8),
- clipBehavior: Clip.antiAlias,
- child: SingleChildScrollView(
- padding: const EdgeInsets.all(8),
- // to show a scroll bar, we would need to provide a scroll controller
- // to both the `Scrollable` and the `Scrollbar`, but
- // as of Flutter v3.0.0, `SelectableText` does not allow passing the `scrollController`
- child: SelectableText(
- info,
- textDirection: ui.TextDirection.ltr,
- style: Theme.of(context).textTheme.bodySmall,
- ),
- ),
- );
- },
- ),
- _buildStep(3, l10n.aboutBugReportInstruction, l10n.aboutBugReportButton, _goToGithub),
- const SizedBox(height: 16),
- ],
+ child: Text(context.l10n.aboutBugSectionTitle, style: AStyles.knownTitleText),
),
),
+ body: const BugReportContent(),
isExpanded: _showInstructions,
canTapOnHeader: true,
backgroundColor: Colors.transparent,
@@ -120,6 +67,73 @@ class _BugReportState extends State with FeedbackMixin {
],
);
}
+}
+
+class BugReportContent extends StatefulWidget {
+ const BugReportContent({super.key});
+
+ @override
+ State createState() => _BugReportContentState();
+}
+
+class _BugReportContentState extends State with FeedbackMixin {
+ late Future _infoLoader;
+ static const bugReportUrl = '${AppReference.avesGithub}/issues/new?labels=type%3Abug&template=bug_report.md';
+
+ @override
+ void initState() {
+ super.initState();
+ _infoLoader = _getInfo(context);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final l10n = context.l10n;
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _buildStep(1, l10n.aboutBugSaveLogInstruction, l10n.saveTooltip, _saveLogs),
+ _buildStep(2, l10n.aboutBugCopyInfoInstruction, l10n.aboutBugCopyInfoButton, _copySystemInfo),
+ FutureBuilder(
+ future: _infoLoader,
+ builder: (context, snapshot) {
+ final info = snapshot.data;
+ if (info == null) return const SizedBox();
+
+ return Container(
+ decoration: BoxDecoration(
+ color: Themes.secondLayerColor(context),
+ border: Border.all(
+ color: Theme.of(context).dividerColor,
+ width: AvesBorder.curvedBorderWidth(context),
+ ),
+ borderRadius: const BorderRadius.all(Radius.circular(16)),
+ ),
+ constraints: const BoxConstraints(maxHeight: 100),
+ margin: const EdgeInsets.symmetric(vertical: 8),
+ clipBehavior: Clip.antiAlias,
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(8),
+ // to show a scroll bar, we would need to provide a scroll controller
+ // to both the `Scrollable` and the `Scrollbar`, but
+ // as of Flutter v3.0.0, `SelectableText` does not allow passing the `scrollController`
+ child: SelectableText(
+ info,
+ textDirection: ui.TextDirection.ltr,
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ),
+ );
+ },
+ ),
+ _buildStep(3, l10n.aboutBugReportInstruction, l10n.aboutBugReportButton, _goToGithub),
+ const SizedBox(height: 8),
+ ],
+ ),
+ );
+ }
Widget _buildStep(int step, String text, String buttonText, VoidCallback onPressed) {
final isMonochrome = settings.themeColorMode == AvesThemeColorMode.monochrome;
diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart
index 967762ad6..c5e14a22b 100644
--- a/lib/widgets/aves_app.dart
+++ b/lib/widgets/aves_app.dart
@@ -36,7 +36,7 @@ import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/providers/viewer_entry_provider.dart';
import 'package:aves/widgets/dialogs/entry_editors/edit_location_dialog.dart';
-import 'package:aves/widgets/home_page.dart';
+import 'package:aves/widgets/home/home_page.dart';
import 'package:aves/widgets/navigation/tv_page_transitions.dart';
import 'package:aves/widgets/navigation/tv_rail.dart';
import 'package:aves/widgets/welcome_page.dart';
diff --git a/lib/widgets/home/home_error.dart b/lib/widgets/home/home_error.dart
new file mode 100644
index 000000000..296ffb4ff
--- /dev/null
+++ b/lib/widgets/home/home_error.dart
@@ -0,0 +1,107 @@
+import 'package:aves/ref/locales.dart';
+import 'package:aves/ref/mime_types.dart';
+import 'package:aves/services/common/services.dart';
+import 'package:aves/widgets/about/bug_report.dart';
+import 'package:aves/widgets/common/action_mixins/feedback.dart';
+import 'package:aves/widgets/common/extensions/build_context.dart';
+import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
+import 'package:aves/widgets/common/identity/buttons/outlined_button.dart';
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+
+class HomeError extends StatefulWidget {
+ final Object error;
+ final StackTrace stack;
+
+ const HomeError({
+ super.key,
+ required this.error,
+ required this.stack,
+ });
+
+ @override
+ State createState() => _HomeErrorState();
+}
+
+class _HomeErrorState extends State with FeedbackMixin {
+ final ValueNotifier _expandedNotifier = ValueNotifier(null);
+
+ @override
+ void dispose() {
+ _expandedNotifier.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final l10n = context.l10n;
+ return SafeArea(
+ bottom: false,
+ child: CustomScrollView(
+ slivers: [
+ SliverPadding(
+ padding: const EdgeInsets.symmetric(horizontal: 8),
+ sliver: SliverList(
+ delegate: SliverChildListDelegate(
+ [
+ AvesExpansionTile(
+ title: 'Error',
+ expandedNotifier: _expandedNotifier,
+ showHighlight: false,
+ children: [
+ Padding(
+ padding: const EdgeInsets.all(8),
+ child: SelectableText(
+ '${widget.error}:\n${widget.stack}',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ),
+ ],
+ ),
+ AvesExpansionTile(
+ title: l10n.aboutBugSectionTitle,
+ expandedNotifier: _expandedNotifier,
+ showHighlight: false,
+ children: const [BugReportContent()],
+ ),
+ AvesExpansionTile(
+ title: l10n.aboutDataUsageDatabase,
+ expandedNotifier: _expandedNotifier,
+ showHighlight: false,
+ children: [
+ Row(
+ children: [
+ Padding(
+ padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
+ child: AvesOutlinedButton(
+ label: l10n.settingsActionExport,
+ onPressed: () async {
+ final sourcePath = await localMediaDb.path;
+ final success = await storageService.copyFile(
+ 'aves-database-${DateFormat('yyyyMMdd_HHmmss', asciiLocale).format(DateTime.now())}.db',
+ MimeTypes.sqlite3,
+ Uri.file(sourcePath).toString(),
+ );
+ if (success != null) {
+ if (success) {
+ showFeedback(context, FeedbackType.info, context.l10n.genericSuccessFeedback);
+ } else {
+ showFeedback(context, FeedbackType.warn, context.l10n.genericFailureFeedback);
+ }
+ }
+ },
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/widgets/home_page.dart b/lib/widgets/home/home_page.dart
similarity index 54%
rename from lib/widgets/home_page.dart
rename to lib/widgets/home/home_page.dart
index dfc828a07..8ea841bf0 100644
--- a/lib/widgets/home_page.dart
+++ b/lib/widgets/home/home_page.dart
@@ -7,9 +7,9 @@ import 'package:aves/model/app/permissions.dart';
import 'package:aves/model/app_inventory.dart';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/catalog.dart';
+import 'package:aves/model/filters/covered/location.dart';
import 'package:aves/model/filters/covered/stored_album.dart';
import 'package:aves/model/filters/filters.dart';
-import 'package:aves/model/filters/covered/location.dart';
import 'package:aves/model/settings/enums/home_page.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
@@ -31,6 +31,7 @@ import 'package:aves/widgets/editor/entry_editor_page.dart';
import 'package:aves/widgets/explorer/explorer_page.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/tags_page.dart';
+import 'package:aves/widgets/home/home_error.dart';
import 'package:aves/widgets/map/map_page.dart';
import 'package:aves/widgets/search/search_delegate.dart';
import 'package:aves/widgets/settings/home_widget_settings_page.dart';
@@ -68,6 +69,7 @@ class _HomePageState extends State {
String? _initialExplorerPath;
(LatLng, double?)? _initialLocationZoom;
List? _secureUris;
+ (Object, StackTrace)? _setupError;
static const allowedShortcutRoutes = [
AlbumListPage.routeName,
@@ -85,183 +87,195 @@ class _HomePageState extends State {
}
@override
- Widget build(BuildContext context) => const AvesScaffold();
+ Widget build(BuildContext context) => AvesScaffold(
+ body: _setupError != null
+ ? HomeError(
+ error: _setupError!.$1,
+ stack: _setupError!.$2,
+ )
+ : null,
+ );
Future _setup() async {
- final stopwatch = Stopwatch()..start();
- if (await windowService.isActivity()) {
- // do not check whether permission was granted, because some app stores
- // hide in some countries apps that force quit on permission denial
- await Permissions.mediaAccess.request();
- }
+ try {
+ final stopwatch = Stopwatch()..start();
+ if (await windowService.isActivity()) {
+ // do not check whether permission was granted, because some app stores
+ // hide in some countries apps that force quit on permission denial
+ await Permissions.mediaAccess.request();
+ }
- var appMode = AppMode.main;
- var error = false;
- final intentData = widget.intentData ?? await IntentService.getIntentData();
- final intentAction = intentData[IntentDataKeys.action] as String?;
- _initialFilters = null;
- _initialExplorerPath = null;
- _secureUris = null;
+ var appMode = AppMode.main;
+ var error = false;
+ final intentData = widget.intentData ?? await IntentService.getIntentData();
+ final intentAction = intentData[IntentDataKeys.action] as String?;
+ _initialFilters = null;
+ _initialExplorerPath = null;
+ _secureUris = null;
- await availability.onNewIntent();
- await androidFileUtils.init();
- if (!{
- IntentActions.edit,
- IntentActions.screenSaver,
- IntentActions.setWallpaper,
- }.contains(intentAction) &&
- settings.isInstalledAppAccessAllowed) {
- unawaited(appInventory.initAppNames());
- }
+ await availability.onNewIntent();
+ await androidFileUtils.init();
+ if (!{
+ IntentActions.edit,
+ IntentActions.screenSaver,
+ IntentActions.setWallpaper,
+ }.contains(intentAction) &&
+ settings.isInstalledAppAccessAllowed) {
+ unawaited(appInventory.initAppNames());
+ }
- if (intentData.values.nonNulls.isNotEmpty) {
- await reportService.log('Intent data=$intentData');
- var intentUri = intentData[IntentDataKeys.uri] as String?;
- final intentMimeType = intentData[IntentDataKeys.mimeType] as String?;
+ if (intentData.values.nonNulls.isNotEmpty) {
+ await reportService.log('Intent data=$intentData');
+ var intentUri = intentData[IntentDataKeys.uri] as String?;
+ final intentMimeType = intentData[IntentDataKeys.mimeType] as String?;
- switch (intentAction) {
- case IntentActions.view:
- appMode = AppMode.view;
- _secureUris = (intentData[IntentDataKeys.secureUris] as List?)?.cast();
- case IntentActions.viewGeo:
- error = true;
- if (intentUri != null) {
- final locationZoom = parseGeoUri(intentUri);
- if (locationZoom != null) {
- _initialRouteName = MapPage.routeName;
- _initialLocationZoom = locationZoom;
- error = false;
- }
- }
- break;
- case IntentActions.edit:
- appMode = AppMode.edit;
- case IntentActions.setWallpaper:
- appMode = AppMode.setWallpaper;
- case IntentActions.pickItems:
- // TODO TLAD apply pick mimetype(s)
- // some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
- final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
- debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
- appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
- case IntentActions.pickCollectionFilters:
- appMode = AppMode.pickCollectionFiltersExternal;
- case IntentActions.screenSaver:
- appMode = AppMode.screenSaver;
- _initialRouteName = ScreenSaverPage.routeName;
- case IntentActions.screenSaverSettings:
- _initialRouteName = ScreenSaverSettingsPage.routeName;
- case IntentActions.search:
- _initialRouteName = SearchPage.routeName;
- _initialSearchQuery = intentData[IntentDataKeys.query] as String?;
- case IntentActions.widgetSettings:
- _initialRouteName = HomeWidgetSettingsPage.routeName;
- _widgetId = (intentData[IntentDataKeys.widgetId] as int?) ?? 0;
- case IntentActions.widgetOpen:
- final widgetId = intentData[IntentDataKeys.widgetId] as int?;
- if (widgetId == null) {
+ switch (intentAction) {
+ case IntentActions.view:
+ appMode = AppMode.view;
+ _secureUris = (intentData[IntentDataKeys.secureUris] as List?)?.cast();
+ case IntentActions.viewGeo:
error = true;
- } else {
- // widget settings may be modified in a different process after channel setup
- await settings.reload();
- final page = settings.getWidgetOpenPage(widgetId);
- switch (page) {
- case WidgetOpenPage.collection:
- _initialFilters = settings.getWidgetCollectionFilters(widgetId);
- case WidgetOpenPage.viewer:
- appMode = AppMode.view;
- intentUri = settings.getWidgetUri(widgetId);
- case WidgetOpenPage.home:
- case WidgetOpenPage.updateWidget:
- break;
+ if (intentUri != null) {
+ final locationZoom = parseGeoUri(intentUri);
+ if (locationZoom != null) {
+ _initialRouteName = MapPage.routeName;
+ _initialLocationZoom = locationZoom;
+ error = false;
+ }
}
- unawaited(WidgetService.update(widgetId));
- }
- default:
- // do not use 'route' as extra key, as the Flutter framework acts on it
- final extraRoute = intentData[IntentDataKeys.page] as String?;
- if (allowedShortcutRoutes.contains(extraRoute)) {
- _initialRouteName = extraRoute;
- }
+ break;
+ case IntentActions.edit:
+ appMode = AppMode.edit;
+ case IntentActions.setWallpaper:
+ appMode = AppMode.setWallpaper;
+ case IntentActions.pickItems:
+ // TODO TLAD apply pick mimetype(s)
+ // some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
+ final multiple = (intentData[IntentDataKeys.allowMultiple] as bool?) ?? false;
+ debugPrint('pick mimeType=$intentMimeType multiple=$multiple');
+ appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
+ case IntentActions.pickCollectionFilters:
+ appMode = AppMode.pickCollectionFiltersExternal;
+ case IntentActions.screenSaver:
+ appMode = AppMode.screenSaver;
+ _initialRouteName = ScreenSaverPage.routeName;
+ case IntentActions.screenSaverSettings:
+ _initialRouteName = ScreenSaverSettingsPage.routeName;
+ case IntentActions.search:
+ _initialRouteName = SearchPage.routeName;
+ _initialSearchQuery = intentData[IntentDataKeys.query] as String?;
+ case IntentActions.widgetSettings:
+ _initialRouteName = HomeWidgetSettingsPage.routeName;
+ _widgetId = (intentData[IntentDataKeys.widgetId] as int?) ?? 0;
+ case IntentActions.widgetOpen:
+ final widgetId = intentData[IntentDataKeys.widgetId] as int?;
+ if (widgetId == null) {
+ error = true;
+ } else {
+ // widget settings may be modified in a different process after channel setup
+ await settings.reload();
+ final page = settings.getWidgetOpenPage(widgetId);
+ switch (page) {
+ case WidgetOpenPage.collection:
+ _initialFilters = settings.getWidgetCollectionFilters(widgetId);
+ case WidgetOpenPage.viewer:
+ appMode = AppMode.view;
+ intentUri = settings.getWidgetUri(widgetId);
+ case WidgetOpenPage.home:
+ case WidgetOpenPage.updateWidget:
+ break;
+ }
+ unawaited(WidgetService.update(widgetId));
+ }
+ default:
+ // do not use 'route' as extra key, as the Flutter framework acts on it
+ final extraRoute = intentData[IntentDataKeys.page] as String?;
+ if (allowedShortcutRoutes.contains(extraRoute)) {
+ _initialRouteName = extraRoute;
+ }
+ }
+ if (_initialFilters == null) {
+ final extraFilters = (intentData[IntentDataKeys.filters] as List?)?.cast();
+ _initialFilters = extraFilters?.map(CollectionFilter.fromJson).nonNulls.toSet();
+ }
+ _initialExplorerPath = intentData[IntentDataKeys.explorerPath] as String?;
+
+ switch (appMode) {
+ case AppMode.view:
+ case AppMode.edit:
+ case AppMode.setWallpaper:
+ if (intentUri != null) {
+ _viewerEntry = await _initViewerEntry(
+ uri: intentUri,
+ mimeType: intentMimeType,
+ );
+ }
+ error = _viewerEntry == null;
+ default:
+ break;
+ }
}
- if (_initialFilters == null) {
- final extraFilters = (intentData[IntentDataKeys.filters] as List?)?.cast();
- _initialFilters = extraFilters?.map(CollectionFilter.fromJson).nonNulls.toSet();
+
+ if (error) {
+ debugPrint('Failed to init app mode=$appMode for intent data=$intentData. Fallback to main mode.');
+ appMode = AppMode.main;
}
- _initialExplorerPath = intentData[IntentDataKeys.explorerPath] as String?;
+
+ context.read>().value = appMode;
+ unawaited(reportService.setCustomKey('app_mode', appMode.toString()));
switch (appMode) {
+ case AppMode.main:
+ case AppMode.pickCollectionFiltersExternal:
+ case AppMode.pickSingleMediaExternal:
+ case AppMode.pickMultipleMediaExternal:
+ unawaited(GlobalSearch.registerCallback());
+ unawaited(AnalysisService.registerCallback());
+ final source = context.read();
+ if (source.loadedScope != CollectionSource.fullScope) {
+ await reportService.log('Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}');
+ final loadTopEntriesFirst = settings.homePage == HomePageSetting.collection && settings.homeCustomCollection.isEmpty;
+ source.canAnalyze = true;
+ await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
+ }
+ case AppMode.screenSaver:
+ await reportService.log('Initialize source to start screen saver');
+ final source = context.read();
+ source.canAnalyze = false;
+ await source.init(scope: settings.screenSaverCollectionFilters);
case AppMode.view:
+ if (_isViewerSourceable(_viewerEntry) && _secureUris == null) {
+ final directory = _viewerEntry?.directory;
+ if (directory != null) {
+ unawaited(AnalysisService.registerCallback());
+ await reportService.log('Initialize source to view item in directory $directory');
+ final source = context.read();
+ // analysis is necessary to display neighbour items when the initial item is a new one
+ source.canAnalyze = true;
+ await source.init(scope: {StoredAlbumFilter(directory, null)});
+ }
+ } else {
+ await _initViewerEssentials();
+ }
case AppMode.edit:
case AppMode.setWallpaper:
- if (intentUri != null) {
- _viewerEntry = await _initViewerEntry(
- uri: intentUri,
- mimeType: intentMimeType,
- );
- }
- error = _viewerEntry == null;
+ await _initViewerEssentials();
default:
break;
}
+
+ debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
+
+ // `pushReplacement` is not enough in some edge cases
+ // e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode
+ unawaited(Navigator.maybeOf(context)?.pushAndRemoveUntil(
+ await _getRedirectRoute(appMode),
+ (route) => false,
+ ));
+ } catch (error, stack) {
+ debugPrint('failed to setup app with error=$error\n$stack');
+ _setupError = (error, stack);
}
-
- if (error) {
- debugPrint('Failed to init app mode=$appMode for intent data=$intentData. Fallback to main mode.');
- appMode = AppMode.main;
- }
-
- context.read>().value = appMode;
- unawaited(reportService.setCustomKey('app_mode', appMode.toString()));
-
- switch (appMode) {
- case AppMode.main:
- case AppMode.pickCollectionFiltersExternal:
- case AppMode.pickSingleMediaExternal:
- case AppMode.pickMultipleMediaExternal:
- unawaited(GlobalSearch.registerCallback());
- unawaited(AnalysisService.registerCallback());
- final source = context.read();
- if (source.loadedScope != CollectionSource.fullScope) {
- await reportService.log('Initialize source to start app with mode=$appMode, loaded scope=${source.loadedScope}');
- final loadTopEntriesFirst = settings.homePage == HomePageSetting.collection && settings.homeCustomCollection.isEmpty;
- source.canAnalyze = true;
- await source.init(scope: CollectionSource.fullScope, loadTopEntriesFirst: loadTopEntriesFirst);
- }
- case AppMode.screenSaver:
- await reportService.log('Initialize source to start screen saver');
- final source = context.read();
- source.canAnalyze = false;
- await source.init(scope: settings.screenSaverCollectionFilters);
- case AppMode.view:
- if (_isViewerSourceable(_viewerEntry) && _secureUris == null) {
- final directory = _viewerEntry?.directory;
- if (directory != null) {
- unawaited(AnalysisService.registerCallback());
- await reportService.log('Initialize source to view item in directory $directory');
- final source = context.read();
- // analysis is necessary to display neighbour items when the initial item is a new one
- source.canAnalyze = true;
- await source.init(scope: {StoredAlbumFilter(directory, null)});
- }
- } else {
- await _initViewerEssentials();
- }
- case AppMode.edit:
- case AppMode.setWallpaper:
- await _initViewerEssentials();
- default:
- break;
- }
-
- debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms');
-
- // `pushReplacement` is not enough in some edge cases
- // e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode
- unawaited(Navigator.maybeOf(context)?.pushAndRemoveUntil(
- await _getRedirectRoute(appMode),
- (route) => false,
- ));
}
Future _initViewerEssentials() async {
diff --git a/lib/widgets/navigation/drawer/app_drawer.dart b/lib/widgets/navigation/drawer/app_drawer.dart
index 95580471f..55fe4a623 100644
--- a/lib/widgets/navigation/drawer/app_drawer.dart
+++ b/lib/widgets/navigation/drawer/app_drawer.dart
@@ -26,7 +26,7 @@ import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/countries_page.dart';
import 'package:aves/widgets/filter_grids/places_page.dart';
import 'package:aves/widgets/filter_grids/tags_page.dart';
-import 'package:aves/widgets/home_page.dart';
+import 'package:aves/widgets/home/home_page.dart';
import 'package:aves/widgets/navigation/drawer/collection_nav_tile.dart';
import 'package:aves/widgets/navigation/drawer/page_nav_tile.dart';
import 'package:aves/widgets/navigation/drawer/tile.dart';
diff --git a/lib/widgets/navigation/nav_display.dart b/lib/widgets/navigation/nav_display.dart
index 1b3668784..20ac5dd4d 100644
--- a/lib/widgets/navigation/nav_display.dart
+++ b/lib/widgets/navigation/nav_display.dart
@@ -12,7 +12,7 @@ import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/countries_page.dart';
import 'package:aves/widgets/filter_grids/places_page.dart';
import 'package:aves/widgets/filter_grids/tags_page.dart';
-import 'package:aves/widgets/home_page.dart';
+import 'package:aves/widgets/home/home_page.dart';
import 'package:aves/widgets/settings/settings_page.dart';
import 'package:flutter/material.dart';
diff --git a/lib/widgets/welcome_page.dart b/lib/widgets/welcome_page.dart
index 1ad184eab..2ac1e211a 100644
--- a/lib/widgets/welcome_page.dart
+++ b/lib/widgets/welcome_page.dart
@@ -11,7 +11,7 @@ import 'package:aves/widgets/common/basic/scaffold.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_logo.dart';
import 'package:aves/widgets/common/identity/buttons/outlined_button.dart';
-import 'package:aves/widgets/home_page.dart';
+import 'package:aves/widgets/home/home_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';