From cb067aa1aceca747101089e3e1002945688d428d Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 16 Mar 2025 17:17:45 +0100 Subject: [PATCH] #1476 launch error handling; DB: table existence check in v13+ upgrades --- CHANGELOG.md | 5 + .../streams/ActivityResultStreamHandler.kt | 44 +++ lib/model/db/db.dart | 2 + lib/model/db/db_extension.dart | 15 + lib/model/db/db_sqflite.dart | 95 +---- lib/model/db/db_sqflite_schema.dart | 118 +++++++ lib/model/db/db_sqflite_upgrade.dart | 279 +++++++-------- lib/ref/mime_types.dart | 1 + lib/services/storage_service.dart | 28 ++ lib/widgets/about/bug_report.dart | 126 ++++--- lib/widgets/aves_app.dart | 2 +- lib/widgets/home/home_error.dart | 107 ++++++ lib/widgets/{ => home}/home_page.dart | 332 +++++++++--------- lib/widgets/navigation/drawer/app_drawer.dart | 2 +- lib/widgets/navigation/nav_display.dart | 2 +- lib/widgets/welcome_page.dart | 2 +- 16 files changed, 722 insertions(+), 438 deletions(-) create mode 100644 lib/model/db/db_extension.dart create mode 100644 lib/model/db/db_sqflite_schema.dart create mode 100644 lib/widgets/home/home_error.dart rename lib/widgets/{ => home}/home_page.dart (54%) 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';