#1476 launch error handling;

DB: table existence check in v13+ upgrades
This commit is contained in:
Thibault Deckers 2025-03-16 17:17:45 +01:00
parent cf74e75d58
commit cb067aa1ac
16 changed files with 722 additions and 438 deletions

View file

@ -4,8 +4,13 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased] ## <a id="unreleased"></a>[Unreleased]
### Added
- handle launch error to report and export DB
### Changed ### Changed
- DB post-upgrade sanitization
- upgraded Flutter to stable v3.29.2 - upgraded Flutter to stable v3.29.2
## <a id="v1.12.6"></a>[v1.12.6] - 2025-03-11 ## <a id="v1.12.6"></a>[v1.12.6] - 2025-03-11

View file

@ -49,6 +49,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
"requestMediaFileAccess" -> ioScope.launch { requestMediaFileAccess() } "requestMediaFileAccess" -> ioScope.launch { requestMediaFileAccess() }
"createFile" -> ioScope.launch { createFile() } "createFile" -> ioScope.launch { createFile() }
"openFile" -> ioScope.launch { openFile() } "openFile" -> ioScope.launch { openFile() }
"copyFile" -> ioScope.launch { copyFile() }
"edit" -> edit() "edit" -> edit()
"pickCollectionFilters" -> pickCollectionFilters() "pickCollectionFilters" -> pickCollectionFilters()
else -> endOfStream() else -> endOfStream()
@ -181,6 +182,49 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
safeStartActivityForStorageAccessResult(intent, MainActivity.OPEN_FILE_REQUEST, ::onGranted, ::onDenied) 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() { private fun edit() {
val uri = args["uri"] as String? val uri = args["uri"] as String?
val mimeType = args["mimeType"] as String? // optional val mimeType = args["mimeType"] as String? // optional

View file

@ -12,6 +12,8 @@ import 'package:aves/model/viewer/video_playback.dart';
abstract class LocalMediaDb { abstract class LocalMediaDb {
int get nextId; int get nextId;
Future<String> get path;
Future<void> init(); Future<void> init();
Future<int> dbFileSize(); Future<int> dbFileSize();

View file

@ -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;
}
}
}

View file

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:aves/model/covers.dart'; import 'package:aves/model/covers.dart';
import 'package:aves/model/db/db.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/db/db_sqflite_upgrade.dart';
import 'package:aves/model/dynamic_albums.dart'; import 'package:aves/model/dynamic_albums.dart';
import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/entry.dart';
@ -20,18 +21,19 @@ import 'package:sqflite/sqflite.dart';
class SqfliteLocalMediaDb implements LocalMediaDb { class SqfliteLocalMediaDb implements LocalMediaDb {
late Database _db; late Database _db;
@override
Future<String> get path async => pContext.join(await getDatabasesPath(), 'metadata.db'); Future<String> get path async => pContext.join(await getDatabasesPath(), 'metadata.db');
static const entryTable = 'entry'; static const entryTable = SqfliteLocalMediaDbSchema.entryTable;
static const dateTakenTable = 'dateTaken'; static const dateTakenTable = SqfliteLocalMediaDbSchema.dateTakenTable;
static const metadataTable = 'metadata'; static const metadataTable = SqfliteLocalMediaDbSchema.metadataTable;
static const addressTable = 'address'; static const addressTable = SqfliteLocalMediaDbSchema.addressTable;
static const favouriteTable = 'favourites'; static const favouriteTable = SqfliteLocalMediaDbSchema.favouriteTable;
static const coverTable = 'covers'; static const coverTable = SqfliteLocalMediaDbSchema.coverTable;
static const dynamicAlbumTable = 'dynamicAlbums'; static const dynamicAlbumTable = SqfliteLocalMediaDbSchema.dynamicAlbumTable;
static const vaultTable = 'vaults'; static const vaultTable = SqfliteLocalMediaDbSchema.vaultTable;
static const trashTable = 'trash'; static const trashTable = SqfliteLocalMediaDbSchema.trashTable;
static const videoPlaybackTable = 'videoPlayback'; static const videoPlaybackTable = SqfliteLocalMediaDbSchema.videoPlaybackTable;
static const _entryInsertSliceMaxCount = 10000; // number of entries static const _entryInsertSliceMaxCount = 10000; // number of entries
static const _queryCursorBufferSize = 1000; // number of rows static const _queryCursorBufferSize = 1000; // number of rows
@ -44,78 +46,7 @@ class SqfliteLocalMediaDb implements LocalMediaDb {
Future<void> init() async { Future<void> init() async {
_db = await openDatabase( _db = await openDatabase(
await path, await path,
onCreate: (db, version) async { onCreate: (db, version) => SqfliteLocalMediaDbSchema.createLatestVersion(db),
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'
')');
},
onUpgrade: LocalMediaDbUpgrader.upgradeDb, onUpgrade: LocalMediaDbUpgrader.upgradeDb,
version: 15, version: 15,
); );

View file

@ -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<void> createLatestVersion(Database db) async {
await Future.forEach(allTables, (table) => createTable(db, table));
}
static Future<void> 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');
}
}
}

View file

@ -1,22 +1,23 @@
import 'dart:ui'; import 'dart:ui';
import 'package:aves/model/covers.dart'; 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:aves/model/filters/filters.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
class LocalMediaDbUpgrader { class LocalMediaDbUpgrader {
static const entryTable = SqfliteLocalMediaDb.entryTable; static const entryTable = SqfliteLocalMediaDbSchema.entryTable;
static const dateTakenTable = SqfliteLocalMediaDb.dateTakenTable; static const dateTakenTable = SqfliteLocalMediaDbSchema.dateTakenTable;
static const metadataTable = SqfliteLocalMediaDb.metadataTable; static const metadataTable = SqfliteLocalMediaDbSchema.metadataTable;
static const addressTable = SqfliteLocalMediaDb.addressTable; static const addressTable = SqfliteLocalMediaDbSchema.addressTable;
static const favouriteTable = SqfliteLocalMediaDb.favouriteTable; static const favouriteTable = SqfliteLocalMediaDbSchema.favouriteTable;
static const coverTable = SqfliteLocalMediaDb.coverTable; static const coverTable = SqfliteLocalMediaDbSchema.coverTable;
static const dynamicAlbumTable = SqfliteLocalMediaDb.dynamicAlbumTable; static const dynamicAlbumTable = SqfliteLocalMediaDbSchema.dynamicAlbumTable;
static const vaultTable = SqfliteLocalMediaDb.vaultTable; static const vaultTable = SqfliteLocalMediaDbSchema.vaultTable;
static const trashTable = SqfliteLocalMediaDb.trashTable; static const trashTable = SqfliteLocalMediaDbSchema.trashTable;
static const videoPlaybackTable = SqfliteLocalMediaDb.videoPlaybackTable; static const videoPlaybackTable = SqfliteLocalMediaDbSchema.videoPlaybackTable;
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported // warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported
// on SQLite <3.25.0, bundled on older Android devices // on SQLite <3.25.0, bundled on older Android devices
@ -55,55 +56,68 @@ class LocalMediaDbUpgrader {
} }
oldVersion++; oldVersion++;
} }
await _sanitize(db);
}
static Future<void> _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<void> _upgradeFrom1(Database db) async { static Future<void> _upgradeFrom1(Database db) async {
debugPrint('upgrading DB from v1'); debugPrint('upgrading DB from v1');
// rename column 'orientationDegrees' to 'sourceRotationDegrees' // rename column 'orientationDegrees' to 'sourceRotationDegrees'
await db.transaction((txn) async { const newEntryTable = '${entryTable}TEMP';
const newEntryTable = '${entryTable}TEMP'; await db.execute('CREATE TABLE $newEntryTable ('
await db.execute('CREATE TABLE $newEntryTable(' 'contentId INTEGER PRIMARY KEY'
'contentId INTEGER PRIMARY KEY' ', uri TEXT'
', uri TEXT' ', path TEXT'
', path TEXT' ', sourceMimeType TEXT'
', sourceMimeType TEXT' ', width INTEGER'
', width INTEGER' ', height INTEGER'
', height INTEGER' ', sourceRotationDegrees INTEGER'
', sourceRotationDegrees INTEGER' ', sizeBytes INTEGER'
', sizeBytes INTEGER' ', title TEXT'
', title TEXT' ', dateModifiedSecs INTEGER'
', dateModifiedSecs INTEGER' ', sourceDateTakenMillis INTEGER'
', sourceDateTakenMillis INTEGER' ', durationMillis INTEGER'
', durationMillis INTEGER' ')');
')'); await db.rawInsert('INSERT INTO $newEntryTable (contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)'
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'
' SELECT contentId,uri,path,sourceMimeType,width,height,orientationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis' ' FROM $entryTable;');
' FROM $entryTable;'); await db.execute('DROP TABLE $entryTable;');
await db.execute('DROP TABLE $entryTable;'); await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;');
await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;');
});
// rename column 'videoRotation' to 'rotationDegrees' // rename column 'videoRotation' to 'rotationDegrees'
await db.transaction((txn) async { const newMetadataTable = '${metadataTable}TEMP';
const newMetadataTable = '${metadataTable}TEMP'; await db.execute('CREATE TABLE $newMetadataTable ('
await db.execute('CREATE TABLE $newMetadataTable(' 'contentId INTEGER PRIMARY KEY'
'contentId INTEGER PRIMARY KEY' ', mimeType TEXT'
', mimeType TEXT' ', dateMillis INTEGER'
', dateMillis INTEGER' ', isAnimated INTEGER'
', isAnimated INTEGER' ', rotationDegrees INTEGER'
', rotationDegrees INTEGER' ', xmpSubjects TEXT'
', xmpSubjects TEXT' ', xmpTitleDescription TEXT'
', xmpTitleDescription TEXT' ', latitude REAL'
', latitude REAL' ', longitude REAL'
', longitude REAL' ')');
')'); await db.rawInsert('INSERT INTO $newMetadataTable (contentId,mimeType,dateMillis,isAnimated,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)'
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'
' SELECT contentId,mimeType,dateMillis,isAnimated,videoRotation,xmpSubjects,xmpTitleDescription,latitude,longitude' ' FROM $metadataTable;');
' FROM $metadataTable;'); await db.rawInsert('UPDATE $newMetadataTable SET rotationDegrees = NULL WHERE rotationDegrees = 0;');
await db.rawInsert('UPDATE $newMetadataTable SET rotationDegrees = NULL WHERE rotationDegrees = 0;'); await db.execute('DROP TABLE $metadataTable;');
await db.execute('DROP TABLE $metadataTable;'); await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
});
// new column 'isFlipped' // new column 'isFlipped'
await db.execute('ALTER TABLE $metadataTable ADD COLUMN isFlipped INTEGER;'); await db.execute('ALTER TABLE $metadataTable ADD COLUMN isFlipped INTEGER;');
@ -111,31 +125,30 @@ class LocalMediaDbUpgrader {
static Future<void> _upgradeFrom2(Database db) async { static Future<void> _upgradeFrom2(Database db) async {
debugPrint('upgrading DB from v2'); debugPrint('upgrading DB from v2');
// merge columns 'isAnimated' and 'isFlipped' into 'flags' // merge columns 'isAnimated' and 'isFlipped' into 'flags'
await db.transaction((txn) async { const newMetadataTable = '${metadataTable}TEMP';
const newMetadataTable = '${metadataTable}TEMP'; await db.execute('CREATE TABLE $newMetadataTable ('
await db.execute('CREATE TABLE $newMetadataTable(' 'contentId INTEGER PRIMARY KEY'
'contentId INTEGER PRIMARY KEY' ', mimeType TEXT'
', mimeType TEXT' ', dateMillis INTEGER'
', dateMillis INTEGER' ', flags INTEGER'
', flags INTEGER' ', rotationDegrees INTEGER'
', rotationDegrees INTEGER' ', xmpSubjects TEXT'
', xmpSubjects TEXT' ', xmpTitleDescription TEXT'
', xmpTitleDescription TEXT' ', latitude REAL'
', latitude REAL' ', longitude REAL'
', longitude REAL' ')');
')'); await db.rawInsert('INSERT INTO $newMetadataTable (contentId,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)'
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'
' SELECT contentId,mimeType,dateMillis,ifnull(isAnimated,0)+ifnull(isFlipped,0)*2,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude' ' FROM $metadataTable;');
' FROM $metadataTable;'); await db.execute('DROP TABLE $metadataTable;');
await db.execute('DROP TABLE $metadataTable;'); await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
});
} }
static Future<void> _upgradeFrom3(Database db) async { static Future<void> _upgradeFrom3(Database db) async {
debugPrint('upgrading DB from v3'); debugPrint('upgrading DB from v3');
await db.execute('CREATE TABLE $coverTable(' await db.execute('CREATE TABLE $coverTable ('
'filter TEXT PRIMARY KEY' 'filter TEXT PRIMARY KEY'
', contentId INTEGER' ', contentId INTEGER'
')'); ')');
@ -143,7 +156,7 @@ class LocalMediaDbUpgrader {
static Future<void> _upgradeFrom4(Database db) async { static Future<void> _upgradeFrom4(Database db) async {
debugPrint('upgrading DB from v4'); debugPrint('upgrading DB from v4');
await db.execute('CREATE TABLE $videoPlaybackTable(' await db.execute('CREATE TABLE $videoPlaybackTable ('
'contentId INTEGER PRIMARY KEY' 'contentId INTEGER PRIMARY KEY'
', resumeTimeMillis INTEGER' ', resumeTimeMillis INTEGER'
')'); ')');
@ -160,7 +173,7 @@ class LocalMediaDbUpgrader {
// new column `trashed` // new column `trashed`
await db.transaction((txn) async { await db.transaction((txn) async {
const newEntryTable = '${entryTable}TEMP'; const newEntryTable = '${entryTable}TEMP';
await db.execute('CREATE TABLE $newEntryTable(' await db.execute('CREATE TABLE $newEntryTable ('
'id INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', contentId INTEGER' ', contentId INTEGER'
', uri TEXT' ', uri TEXT'
@ -176,7 +189,7 @@ class LocalMediaDbUpgrader {
', durationMillis INTEGER' ', durationMillis INTEGER'
', trashed INTEGER DEFAULT 0' ', 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' ' SELECT contentId,contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis'
' FROM $entryTable;'); ' FROM $entryTable;');
await db.execute('DROP TABLE $entryTable;'); await db.execute('DROP TABLE $entryTable;');
@ -186,11 +199,11 @@ class LocalMediaDbUpgrader {
// rename column `contentId` to `id` // rename column `contentId` to `id`
await db.transaction((txn) async { await db.transaction((txn) async {
const newDateTakenTable = '${dateTakenTable}TEMP'; const newDateTakenTable = '${dateTakenTable}TEMP';
await db.execute('CREATE TABLE $newDateTakenTable(' await db.execute('CREATE TABLE $newDateTakenTable ('
'id INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', dateMillis INTEGER' ', dateMillis INTEGER'
')'); ')');
await db.rawInsert('INSERT INTO $newDateTakenTable(id,dateMillis)' await db.rawInsert('INSERT INTO $newDateTakenTable (id,dateMillis)'
' SELECT contentId,dateMillis' ' SELECT contentId,dateMillis'
' FROM $dateTakenTable;'); ' FROM $dateTakenTable;');
await db.execute('DROP TABLE $dateTakenTable;'); await db.execute('DROP TABLE $dateTakenTable;');
@ -200,7 +213,7 @@ class LocalMediaDbUpgrader {
// rename column `contentId` to `id` // rename column `contentId` to `id`
await db.transaction((txn) async { await db.transaction((txn) async {
const newMetadataTable = '${metadataTable}TEMP'; const newMetadataTable = '${metadataTable}TEMP';
await db.execute('CREATE TABLE $newMetadataTable(' await db.execute('CREATE TABLE $newMetadataTable ('
'id INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', mimeType TEXT' ', mimeType TEXT'
', dateMillis INTEGER' ', dateMillis INTEGER'
@ -212,7 +225,7 @@ class LocalMediaDbUpgrader {
', longitude REAL' ', longitude REAL'
', rating INTEGER' ', 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' ' SELECT contentId,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude,rating'
' FROM $metadataTable;'); ' FROM $metadataTable;');
await db.execute('DROP TABLE $metadataTable;'); await db.execute('DROP TABLE $metadataTable;');
@ -222,7 +235,7 @@ class LocalMediaDbUpgrader {
// rename column `contentId` to `id` // rename column `contentId` to `id`
await db.transaction((txn) async { await db.transaction((txn) async {
const newAddressTable = '${addressTable}TEMP'; const newAddressTable = '${addressTable}TEMP';
await db.execute('CREATE TABLE $newAddressTable(' await db.execute('CREATE TABLE $newAddressTable ('
'id INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', addressLine TEXT' ', addressLine TEXT'
', countryCode TEXT' ', countryCode TEXT'
@ -230,7 +243,7 @@ class LocalMediaDbUpgrader {
', adminArea TEXT' ', adminArea TEXT'
', locality 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' ' SELECT contentId,addressLine,countryCode,countryName,adminArea,locality'
' FROM $addressTable;'); ' FROM $addressTable;');
await db.execute('DROP TABLE $addressTable;'); await db.execute('DROP TABLE $addressTable;');
@ -240,11 +253,11 @@ class LocalMediaDbUpgrader {
// rename column `contentId` to `id` // rename column `contentId` to `id`
await db.transaction((txn) async { await db.transaction((txn) async {
const newVideoPlaybackTable = '${videoPlaybackTable}TEMP'; const newVideoPlaybackTable = '${videoPlaybackTable}TEMP';
await db.execute('CREATE TABLE $newVideoPlaybackTable(' await db.execute('CREATE TABLE $newVideoPlaybackTable ('
'id INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', resumeTimeMillis INTEGER' ', resumeTimeMillis INTEGER'
')'); ')');
await db.rawInsert('INSERT INTO $newVideoPlaybackTable(id,resumeTimeMillis)' await db.rawInsert('INSERT INTO $newVideoPlaybackTable (id,resumeTimeMillis)'
' SELECT contentId,resumeTimeMillis' ' SELECT contentId,resumeTimeMillis'
' FROM $videoPlaybackTable;'); ' FROM $videoPlaybackTable;');
await db.execute('DROP TABLE $videoPlaybackTable;'); await db.execute('DROP TABLE $videoPlaybackTable;');
@ -255,10 +268,10 @@ class LocalMediaDbUpgrader {
// remove column `path` // remove column `path`
await db.transaction((txn) async { await db.transaction((txn) async {
const newFavouriteTable = '${favouriteTable}TEMP'; const newFavouriteTable = '${favouriteTable}TEMP';
await db.execute('CREATE TABLE $newFavouriteTable(' await db.execute('CREATE TABLE $newFavouriteTable ('
'id INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
')'); ')');
await db.rawInsert('INSERT INTO $newFavouriteTable(id)' await db.rawInsert('INSERT INTO $newFavouriteTable (id)'
' SELECT contentId' ' SELECT contentId'
' FROM $favouriteTable;'); ' FROM $favouriteTable;');
await db.execute('DROP TABLE $favouriteTable;'); await db.execute('DROP TABLE $favouriteTable;');
@ -268,11 +281,11 @@ class LocalMediaDbUpgrader {
// rename column `contentId` to `entryId` // rename column `contentId` to `entryId`
await db.transaction((txn) async { await db.transaction((txn) async {
const newCoverTable = '${coverTable}TEMP'; const newCoverTable = '${coverTable}TEMP';
await db.execute('CREATE TABLE $newCoverTable(' await db.execute('CREATE TABLE $newCoverTable ('
'filter TEXT PRIMARY KEY' 'filter TEXT PRIMARY KEY'
', entryId INTEGER' ', entryId INTEGER'
')'); ')');
await db.rawInsert('INSERT INTO $newCoverTable(filter,entryId)' await db.rawInsert('INSERT INTO $newCoverTable (filter,entryId)'
' SELECT filter,contentId' ' SELECT filter,contentId'
' FROM $coverTable;'); ' FROM $coverTable;');
await db.execute('DROP TABLE $coverTable;'); await db.execute('DROP TABLE $coverTable;');
@ -280,7 +293,7 @@ class LocalMediaDbUpgrader {
}); });
// new table // new table
await db.execute('CREATE TABLE $trashTable(' await db.execute('CREATE TABLE $trashTable ('
'id INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', path TEXT' ', path TEXT'
', dateMillis INTEGER' ', dateMillis INTEGER'
@ -299,7 +312,7 @@ class LocalMediaDbUpgrader {
// new column `dateAddedSecs` // new column `dateAddedSecs`
await db.transaction((txn) async { await db.transaction((txn) async {
const newEntryTable = '${entryTable}TEMP'; const newEntryTable = '${entryTable}TEMP';
await db.execute('CREATE TABLE $newEntryTable(' await db.execute('CREATE TABLE $newEntryTable ('
'id INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', contentId INTEGER' ', contentId INTEGER'
', uri TEXT' ', uri TEXT'
@ -316,7 +329,7 @@ class LocalMediaDbUpgrader {
', durationMillis INTEGER' ', durationMillis INTEGER'
', trashed INTEGER DEFAULT 0' ', 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' ' SELECT id,contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis,trashed'
' FROM $entryTable;'); ' FROM $entryTable;');
await db.execute('DROP TABLE $entryTable;'); await db.execute('DROP TABLE $entryTable;');
@ -326,7 +339,7 @@ class LocalMediaDbUpgrader {
// rename column `xmpTitleDescription` to `xmpTitle` // rename column `xmpTitleDescription` to `xmpTitle`
await db.transaction((txn) async { await db.transaction((txn) async {
const newMetadataTable = '${metadataTable}TEMP'; const newMetadataTable = '${metadataTable}TEMP';
await db.execute('CREATE TABLE $newMetadataTable(' await db.execute('CREATE TABLE $newMetadataTable ('
'id INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', mimeType TEXT' ', mimeType TEXT'
', dateMillis INTEGER' ', dateMillis INTEGER'
@ -338,7 +351,7 @@ class LocalMediaDbUpgrader {
', longitude REAL' ', longitude REAL'
', rating INTEGER' ', 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' ' SELECT id,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude,rating'
' FROM $metadataTable;'); ' FROM $metadataTable;');
await db.execute('DROP TABLE $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('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' 'name TEXT PRIMARY KEY'
', autoLock INTEGER' ', autoLock INTEGER'
', useBin INTEGER' ', useBin INTEGER'
@ -394,7 +407,7 @@ class LocalMediaDbUpgrader {
static Future<void> _upgradeFrom11(Database db) async { static Future<void> _upgradeFrom11(Database db) async {
debugPrint('upgrading DB from v11'); debugPrint('upgrading DB from v11');
await db.execute('CREATE TABLE $dynamicAlbumTable(' await db.execute('CREATE TABLE $dynamicAlbumTable ('
'name TEXT PRIMARY KEY' 'name TEXT PRIMARY KEY'
', filter TEXT' ', filter TEXT'
')'); ')');
@ -423,40 +436,38 @@ class LocalMediaDbUpgrader {
} }
// convert `color` column type from value number to JSON string // convert `color` column type from value number to JSON string
await db.transaction((txn) async { const newCoverTable = '${coverTable}TEMP';
const newCoverTable = '${coverTable}TEMP'; await db.execute('CREATE TABLE $newCoverTable ('
await db.execute('CREATE TABLE $newCoverTable(' 'filter TEXT PRIMARY KEY'
'filter TEXT PRIMARY KEY' ', entryId INTEGER'
', entryId INTEGER' ', packageName TEXT'
', packageName TEXT' ', color TEXT'
', color TEXT' ')');
')');
// insert covers with `string` color value // insert covers with `string` color value
if (rows.isNotEmpty) { if (rows.isNotEmpty) {
final batch = db.batch(); final batch = db.batch();
rows.forEach((row) { rows.forEach((row) {
batch.insert( batch.insert(
newCoverTable, newCoverTable,
row.toMap(), row.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace, conflictAlgorithm: ConflictAlgorithm.replace,
); );
}); });
await batch.commit(noResult: true); await batch.commit(noResult: true);
} }
await db.execute('DROP TABLE $coverTable;'); await db.execute('DROP TABLE $coverTable;');
await db.execute('ALTER TABLE $newCoverTable RENAME TO $coverTable;'); await db.execute('ALTER TABLE $newCoverTable RENAME TO $coverTable;');
});
} }
static Future<void> _upgradeFrom13(Database db) async { static Future<void> _upgradeFrom13(Database db) async {
debugPrint('upgrading DB from v13'); debugPrint('upgrading DB from v13');
// rename column 'dateModifiedSecs' to 'dateModifiedMillis' if (db.tableExists(entryTable)) {
await db.transaction((txn) async { // rename column 'dateModifiedSecs' to 'dateModifiedMillis'
const newEntryTable = '${entryTable}TEMP'; const newEntryTable = '${entryTable}TEMP';
await db.execute('CREATE TABLE $newEntryTable(' await db.execute('CREATE TABLE $newEntryTable ('
'id INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', contentId INTEGER' ', contentId INTEGER'
', uri TEXT' ', uri TEXT'
@ -474,30 +485,24 @@ class LocalMediaDbUpgrader {
', trashed INTEGER DEFAULT 0' ', trashed INTEGER DEFAULT 0'
', origin 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' ' SELECT id,contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateAddedSecs,dateModifiedSecs*1000,sourceDateTakenMillis,durationMillis,trashed,origin'
' FROM $entryTable;'); ' FROM $entryTable;');
await db.execute('DROP TABLE $entryTable;'); await db.execute('DROP TABLE $entryTable;');
await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;'); await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;');
}); }
} }
static Future<void> _upgradeFrom14(Database db) async { static Future<void> _upgradeFrom14(Database db) async {
debugPrint('upgrading DB from v14'); debugPrint('upgrading DB from v14');
// no schema changes, but v1.12.4 may have corrupted the DB, so we sanitize it // no schema changes, but v1.12.4 may have corrupted the DB,
// so we clear rebuildable tables
// clear rebuildable tables final tables = [dateTakenTable, metadataTable, addressTable, trashTable, videoPlaybackTable];
await db.delete(dateTakenTable, where: '1'); await Future.forEach(tables, (table) async {
await db.delete(metadataTable, where: '1'); if (db.tableExists(table)) {
await db.delete(addressTable, where: '1'); await db.delete(table, 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]);
} }
} }

View file

@ -75,6 +75,7 @@ class MimeTypes {
static const json = 'application/json'; static const json = 'application/json';
static const plainText = 'text/plain'; static const plainText = 'text/plain';
static const sqlite3 = 'application/vnd.sqlite3';
// JB2, JPC, JPX? // JB2, JPC, JPX?
static const octetStream = 'application/octet-stream'; static const octetStream = 'application/octet-stream';

View file

@ -53,6 +53,9 @@ abstract class StorageService {
Future<bool?> createFile(String name, String mimeType, Uint8List bytes); Future<bool?> createFile(String name, String mimeType, Uint8List bytes);
Future<Uint8List> openFile([String? mimeType]); Future<Uint8List> openFile([String? mimeType]);
// return whether operation succeeded (`null` if user cancelled)
Future<bool?> copyFile(String name, String mimeType, String sourceUri);
} }
class PlatformStorageService implements StorageService { class PlatformStorageService implements StorageService {
@ -369,4 +372,29 @@ class PlatformStorageService implements StorageService {
} }
return Uint8List(0); return Uint8List(0);
} }
@override
Future<bool?> copyFile(String name, String mimeType, String sourceUri) async {
try {
final opCompleter = Completer<bool?>();
_stream.receiveBroadcastStream(<String, dynamic>{
'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;
}
} }

View file

@ -36,21 +36,11 @@ class BugReport extends StatefulWidget {
State<BugReport> createState() => _BugReportState(); State<BugReport> createState() => _BugReportState();
} }
class _BugReportState extends State<BugReport> with FeedbackMixin { class _BugReportState extends State<BugReport> {
late Future<String> _infoLoader;
bool _showInstructions = false; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n;
final animationDuration = context.select<DurationsData, Duration>((v) => v.expansionTileAnimation); final animationDuration = context.select<DurationsData, Duration>((v) => v.expansionTileAnimation);
return ExpansionPanelList( return ExpansionPanelList(
expansionCallback: (index, isExpanded) { expansionCallback: (index, isExpanded) {
@ -66,53 +56,10 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: AlignmentDirectional.centerStart, alignment: AlignmentDirectional.centerStart,
child: Text(l10n.aboutBugSectionTitle, style: AStyles.knownTitleText), child: Text(context.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<String>(
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),
],
), ),
), ),
body: const BugReportContent(),
isExpanded: _showInstructions, isExpanded: _showInstructions,
canTapOnHeader: true, canTapOnHeader: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
@ -120,6 +67,73 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
], ],
); );
} }
}
class BugReportContent extends StatefulWidget {
const BugReportContent({super.key});
@override
State<BugReportContent> createState() => _BugReportContentState();
}
class _BugReportContentState extends State<BugReportContent> with FeedbackMixin {
late Future<String> _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<String>(
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) { Widget _buildStep(int step, String text, String buttonText, VoidCallback onPressed) {
final isMonochrome = settings.themeColorMode == AvesThemeColorMode.monochrome; final isMonochrome = settings.themeColorMode == AvesThemeColorMode.monochrome;

View file

@ -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/media_query_data_provider.dart';
import 'package:aves/widgets/common/providers/viewer_entry_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/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_page_transitions.dart';
import 'package:aves/widgets/navigation/tv_rail.dart'; import 'package:aves/widgets/navigation/tv_rail.dart';
import 'package:aves/widgets/welcome_page.dart'; import 'package:aves/widgets/welcome_page.dart';

View file

@ -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<HomeError> createState() => _HomeErrorState();
}
class _HomeErrorState extends State<HomeError> with FeedbackMixin {
final ValueNotifier<String?> _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);
}
}
},
),
),
],
),
],
),
],
),
),
),
],
),
);
}
}

View file

@ -7,9 +7,9 @@ import 'package:aves/model/app/permissions.dart';
import 'package:aves/model/app_inventory.dart'; import 'package:aves/model/app_inventory.dart';
import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/catalog.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/covered/stored_album.dart';
import 'package:aves/model/filters/filters.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/enums/home_page.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.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/explorer/explorer_page.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/tags_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/map/map_page.dart';
import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/search/search_delegate.dart';
import 'package:aves/widgets/settings/home_widget_settings_page.dart'; import 'package:aves/widgets/settings/home_widget_settings_page.dart';
@ -68,6 +69,7 @@ class _HomePageState extends State<HomePage> {
String? _initialExplorerPath; String? _initialExplorerPath;
(LatLng, double?)? _initialLocationZoom; (LatLng, double?)? _initialLocationZoom;
List<String>? _secureUris; List<String>? _secureUris;
(Object, StackTrace)? _setupError;
static const allowedShortcutRoutes = [ static const allowedShortcutRoutes = [
AlbumListPage.routeName, AlbumListPage.routeName,
@ -85,183 +87,195 @@ class _HomePageState extends State<HomePage> {
} }
@override @override
Widget build(BuildContext context) => const AvesScaffold(); Widget build(BuildContext context) => AvesScaffold(
body: _setupError != null
? HomeError(
error: _setupError!.$1,
stack: _setupError!.$2,
)
: null,
);
Future<void> _setup() async { Future<void> _setup() async {
final stopwatch = Stopwatch()..start(); try {
if (await windowService.isActivity()) { final stopwatch = Stopwatch()..start();
// do not check whether permission was granted, because some app stores if (await windowService.isActivity()) {
// hide in some countries apps that force quit on permission denial // do not check whether permission was granted, because some app stores
await Permissions.mediaAccess.request(); // hide in some countries apps that force quit on permission denial
} await Permissions.mediaAccess.request();
}
var appMode = AppMode.main; var appMode = AppMode.main;
var error = false; var error = false;
final intentData = widget.intentData ?? await IntentService.getIntentData(); final intentData = widget.intentData ?? await IntentService.getIntentData();
final intentAction = intentData[IntentDataKeys.action] as String?; final intentAction = intentData[IntentDataKeys.action] as String?;
_initialFilters = null; _initialFilters = null;
_initialExplorerPath = null; _initialExplorerPath = null;
_secureUris = null; _secureUris = null;
await availability.onNewIntent(); await availability.onNewIntent();
await androidFileUtils.init(); await androidFileUtils.init();
if (!{ if (!{
IntentActions.edit, IntentActions.edit,
IntentActions.screenSaver, IntentActions.screenSaver,
IntentActions.setWallpaper, IntentActions.setWallpaper,
}.contains(intentAction) && }.contains(intentAction) &&
settings.isInstalledAppAccessAllowed) { settings.isInstalledAppAccessAllowed) {
unawaited(appInventory.initAppNames()); unawaited(appInventory.initAppNames());
} }
if (intentData.values.nonNulls.isNotEmpty) { if (intentData.values.nonNulls.isNotEmpty) {
await reportService.log('Intent data=$intentData'); await reportService.log('Intent data=$intentData');
var intentUri = intentData[IntentDataKeys.uri] as String?; var intentUri = intentData[IntentDataKeys.uri] as String?;
final intentMimeType = intentData[IntentDataKeys.mimeType] as String?; final intentMimeType = intentData[IntentDataKeys.mimeType] as String?;
switch (intentAction) { switch (intentAction) {
case IntentActions.view: case IntentActions.view:
appMode = AppMode.view; appMode = AppMode.view;
_secureUris = (intentData[IntentDataKeys.secureUris] as List?)?.cast<String>(); _secureUris = (intentData[IntentDataKeys.secureUris] as List?)?.cast<String>();
case IntentActions.viewGeo: 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) {
error = true; error = true;
} else { if (intentUri != null) {
// widget settings may be modified in a different process after channel setup final locationZoom = parseGeoUri(intentUri);
await settings.reload(); if (locationZoom != null) {
final page = settings.getWidgetOpenPage(widgetId); _initialRouteName = MapPage.routeName;
switch (page) { _initialLocationZoom = locationZoom;
case WidgetOpenPage.collection: error = false;
_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)); break;
} case IntentActions.edit:
default: appMode = AppMode.edit;
// do not use 'route' as extra key, as the Flutter framework acts on it case IntentActions.setWallpaper:
final extraRoute = intentData[IntentDataKeys.page] as String?; appMode = AppMode.setWallpaper;
if (allowedShortcutRoutes.contains(extraRoute)) { case IntentActions.pickItems:
_initialRouteName = extraRoute; // 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<String>();
_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<String>(); if (error) {
_initialFilters = extraFilters?.map(CollectionFilter.fromJson).nonNulls.toSet(); 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<ValueNotifier<AppMode>>().value = appMode;
unawaited(reportService.setCustomKey('app_mode', appMode.toString()));
switch (appMode) { switch (appMode) {
case AppMode.main:
case AppMode.pickCollectionFiltersExternal:
case AppMode.pickSingleMediaExternal:
case AppMode.pickMultipleMediaExternal:
unawaited(GlobalSearch.registerCallback());
unawaited(AnalysisService.registerCallback());
final source = context.read<CollectionSource>();
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<CollectionSource>();
source.canAnalyze = false;
await source.init(scope: settings.screenSaverCollectionFilters);
case AppMode.view: 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<CollectionSource>();
// 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.edit:
case AppMode.setWallpaper: case AppMode.setWallpaper:
if (intentUri != null) { await _initViewerEssentials();
_viewerEntry = await _initViewerEntry(
uri: intentUri,
mimeType: intentMimeType,
);
}
error = _viewerEntry == null;
default: default:
break; 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<ValueNotifier<AppMode>>().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<CollectionSource>();
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<CollectionSource>();
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<CollectionSource>();
// 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<void> _initViewerEssentials() async { Future<void> _initViewerEssentials() async {

View file

@ -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/countries_page.dart';
import 'package:aves/widgets/filter_grids/places_page.dart'; import 'package:aves/widgets/filter_grids/places_page.dart';
import 'package:aves/widgets/filter_grids/tags_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/collection_nav_tile.dart';
import 'package:aves/widgets/navigation/drawer/page_nav_tile.dart'; import 'package:aves/widgets/navigation/drawer/page_nav_tile.dart';
import 'package:aves/widgets/navigation/drawer/tile.dart'; import 'package:aves/widgets/navigation/drawer/tile.dart';

View file

@ -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/countries_page.dart';
import 'package:aves/widgets/filter_grids/places_page.dart'; import 'package:aves/widgets/filter_grids/places_page.dart';
import 'package:aves/widgets/filter_grids/tags_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:aves/widgets/settings/settings_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View file

@ -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/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_logo.dart'; import 'package:aves/widgets/common/identity/aves_logo.dart';
import 'package:aves/widgets/common/identity/buttons/outlined_button.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/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';