#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]
### Added
- handle launch error to report and export DB
### Changed
- DB post-upgrade sanitization
- upgraded Flutter to stable v3.29.2
## <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() }
"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

View file

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

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 '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<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 {
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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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');
}
});
}
}

View file

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

View file

@ -53,6 +53,9 @@ abstract class StorageService {
Future<bool?> createFile(String name, String mimeType, Uint8List bytes);
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 {
@ -369,4 +372,29 @@ class PlatformStorageService implements StorageService {
}
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();
}
class _BugReportState extends State<BugReport> with FeedbackMixin {
late Future<String> _infoLoader;
class _BugReportState extends State<BugReport> {
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<DurationsData, Duration>((v) => v.expansionTileAnimation);
return ExpansionPanelList(
expansionCallback: (index, isExpanded) {
@ -66,53 +56,10 @@ class _BugReportState extends State<BugReport> 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<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),
],
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<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) {
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/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';

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/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<HomePage> {
String? _initialExplorerPath;
(LatLng, double?)? _initialLocationZoom;
List<String>? _secureUris;
(Object, StackTrace)? _setupError;
static const allowedShortcutRoutes = [
AlbumListPage.routeName,
@ -85,183 +87,195 @@ class _HomePageState extends State<HomePage> {
}
@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 {
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<String>();
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<String>();
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<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>();
_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<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:
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<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 {

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/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';

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/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';

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/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';