Files
com.nahnah.florid/lib/services/database_service.dart
T
2026-02-14 22:24:06 +00:00

1034 lines
33 KiB
Dart

import 'dart:convert';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
import '../models/fdroid_app.dart';
class DatabaseService {
static const String _databaseName = 'fdroid_repository.db';
static const int _databaseVersion = 4;
// Table names
static const String _appsTable = 'apps';
static const String _versionsTable = 'versions';
static const String _categoriesTable = 'categories';
static const String _appCategoriesTable = 'app_categories';
static const String _metadataTable = 'metadata';
static const String _repositoriesTable = 'repositories';
Database? _database;
String? _currentLocale;
DatabaseService({String? locale}) : _currentLocale = locale ?? 'en-US';
/// Gets the database instance, creating it if necessary
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDatabase();
return _database!;
}
/// Initializes the database
Future<Database> _initDatabase() async {
final databasesPath = await getDatabasesPath();
final path = join(databasesPath, _databaseName);
return await openDatabase(
path,
version: _databaseVersion,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
}
/// Creates the database schema
Future<void> _onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE $_metadataTable (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
''');
await db.execute('''
CREATE TABLE $_repositoriesTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
is_enabled INTEGER NOT NULL DEFAULT 1,
added_at INTEGER,
last_synced_at INTEGER
)
''');
await db.execute('''
CREATE TABLE $_appsTable (
package_name TEXT PRIMARY KEY,
repository_id INTEGER,
name TEXT NOT NULL,
summary TEXT NOT NULL,
description TEXT NOT NULL,
icon TEXT,
author_name TEXT,
author_email TEXT,
author_website TEXT,
website TEXT,
issue_tracker TEXT,
source_code TEXT,
changelog TEXT,
donate TEXT,
bitcoin TEXT,
flattr_id TEXT,
license TEXT NOT NULL,
suggested_version_name TEXT,
suggested_version_code INTEGER,
added INTEGER,
last_updated INTEGER
)
''');
await db.execute('''
CREATE TABLE $_versionsTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
package_name TEXT NOT NULL,
version_code INTEGER NOT NULL,
version_name TEXT NOT NULL,
size INTEGER NOT NULL,
min_sdk_version TEXT,
target_sdk_version TEXT,
max_sdk_version TEXT,
added INTEGER NOT NULL,
apk_name TEXT NOT NULL,
hash TEXT NOT NULL,
hash_type TEXT NOT NULL,
sig TEXT,
permissions TEXT,
features TEXT,
nativecode TEXT,
whats_new TEXT,
FOREIGN KEY (package_name) REFERENCES $_appsTable (package_name) ON DELETE CASCADE,
UNIQUE (package_name, version_code)
)
''');
await db.execute('''
CREATE TABLE $_categoriesTable (
category TEXT PRIMARY KEY
)
''');
await db.execute('''
CREATE TABLE $_appCategoriesTable (
package_name TEXT NOT NULL,
category TEXT NOT NULL,
PRIMARY KEY (package_name, category),
FOREIGN KEY (package_name) REFERENCES $_appsTable (package_name) ON DELETE CASCADE,
FOREIGN KEY (category) REFERENCES $_categoriesTable (category) ON DELETE CASCADE
)
''');
// Create indices for better query performance
await db.execute('CREATE INDEX idx_apps_name ON $_appsTable (name)');
await db.execute('CREATE INDEX idx_apps_added ON $_appsTable (added)');
await db.execute(
'CREATE INDEX idx_apps_last_updated ON $_appsTable (last_updated)',
);
await db.execute(
'CREATE INDEX idx_versions_package ON $_versionsTable (package_name)',
);
await db.execute(
'CREATE INDEX idx_app_categories_category ON $_appCategoriesTable (category)',
);
}
/// Handles database upgrades
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
// Handle future schema migrations here
if (oldVersion < 2) {
// Create repositories table if upgrading from v1 to v2
await db.execute('''
CREATE TABLE $_repositoriesTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
is_enabled INTEGER NOT NULL DEFAULT 1,
added_at INTEGER,
last_synced_at INTEGER
)
''');
}
if (oldVersion < 3) {
// Add repository_id column to apps table for v2 to v3 upgrade
await db.execute(
'ALTER TABLE $_appsTable ADD COLUMN repository_id INTEGER',
);
}
if (oldVersion < 4) {
// Add whats_new column to versions table for v3 to v4 upgrade
await db.execute('ALTER TABLE $_versionsTable ADD COLUMN whats_new TEXT');
// Force re-sync so new field is populated from repository
await db.delete(
_metadataTable,
where: 'key = ?',
whereArgs: ['last_sync'],
);
}
}
/// Sets the current locale for localized data extraction
/// Note: Currently, localized strings are extracted during JSON import
/// using the FDroidRepository.fromJson method. This locale setting is
/// reserved for future enhancements to support dynamic locale switching.
void setLocale(String locale) {
_currentLocale = locale;
}
/// Gets the current locale
String get currentLocale => _currentLocale ?? 'en-US';
/// Stores repository metadata
Future<void> setMetadata(String key, String value) async {
final db = await database;
await db.insert(_metadataTable, {
'key': key,
'value': value,
'updated_at': DateTime.now().millisecondsSinceEpoch,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
/// Gets repository metadata
Future<String?> getMetadata(String key) async {
final db = await database;
final results = await db.query(
_metadataTable,
where: 'key = ?',
whereArgs: [key],
);
if (results.isEmpty) return null;
return results.first['value'] as String?;
}
/// Checks if the database is populated
Future<bool> isPopulated() async {
final db = await database;
final count = Sqflite.firstIntValue(
await db.rawQuery('SELECT COUNT(*) FROM $_appsTable'),
);
return (count ?? 0) > 0;
}
/// Checks if the database needs update based on timestamp
Future<bool> needsUpdate(Duration maxAge) async {
final timestampStr = await getMetadata('last_sync');
if (timestampStr == null) return true;
final timestamp = int.tryParse(timestampStr);
if (timestamp == null) return true;
final lastSync = DateTime.fromMillisecondsSinceEpoch(timestamp);
final age = DateTime.now().difference(lastSync);
return age > maxAge;
}
/// Imports repository data from FDroidRepository
/// If repositoryId is null, clears all existing data (official F-Droid).
/// If repositoryId is provided, only updates apps from that repository.
Future<void> importRepository(
FDroidRepository repository, {
int? repositoryId,
}) async {
final db = await database;
// Use batch operations for much better performance
final batch = db.batch();
// If no repository ID (official F-Droid), clear all data
if (repositoryId == null) {
batch.delete(_appCategoriesTable);
batch.delete(_versionsTable);
batch.delete(_appsTable);
batch.delete(_categoriesTable);
} else {
// For custom repos, only delete apps from this repository
batch.delete(
_appCategoriesTable,
where:
'''
package_name IN (SELECT package_name FROM $_appsTable WHERE repository_id = ?)
''',
whereArgs: [repositoryId],
);
batch.delete(
_versionsTable,
where:
'''
package_name IN (SELECT package_name FROM $_appsTable WHERE repository_id = ?)
''',
whereArgs: [repositoryId],
);
batch.delete(
_appsTable,
where: 'repository_id = ?',
whereArgs: [repositoryId],
);
}
// Collect unique categories first
final uniqueCategories = <String>{};
for (final app in repository.apps.values) {
if (app.categories != null) {
uniqueCategories.addAll(app.categories!);
}
}
// Insert unique categories
for (final category in uniqueCategories) {
batch.insert(_categoriesTable, {
'category': category,
}, conflictAlgorithm: ConflictAlgorithm.ignore);
}
// Batch insert apps, versions, and app-category relationships
for (final app in repository.apps.values) {
batch.insert(_appsTable, {
'package_name': app.packageName,
'repository_id': repositoryId,
'name': app.name,
'summary': app.summary,
'description': app.description,
'icon': app.icon,
'author_name': app.authorName,
'author_email': app.authorEmail,
'author_website': app.authorWebSite,
'website': app.webSite,
'issue_tracker': app.issueTracker,
'source_code': app.sourceCode,
'changelog': app.changelog,
'donate': app.donate,
'bitcoin': app.bitcoin,
'flattr_id': app.flattrID,
'license': app.license,
'suggested_version_name': app.suggestedVersionName,
'suggested_version_code': app.suggestedVersionCode,
'added': app.added?.millisecondsSinceEpoch,
'last_updated': app.lastUpdated?.millisecondsSinceEpoch,
}, conflictAlgorithm: ConflictAlgorithm.replace);
// Batch insert versions
if (app.packages != null) {
for (final version in app.packages!.values) {
batch.insert(_versionsTable, {
'package_name': app.packageName,
'version_code': version.versionCode,
'version_name': version.versionName,
'size': version.size,
'min_sdk_version': version.minSdkVersion,
'target_sdk_version': version.targetSdkVersion,
'max_sdk_version': version.maxSdkVersion,
'added': version.added.millisecondsSinceEpoch,
'apk_name': version.apkName,
'hash': version.hash,
'hash_type': version.hashType,
'sig': version.sig,
'permissions': version.permissions != null
? jsonEncode(version.permissions)
: null,
'features': version.features != null
? jsonEncode(version.features)
: null,
'nativecode': version.nativecode != null
? jsonEncode(version.nativecode)
: null,
'whats_new': version.whatsNew,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
}
// Batch insert app-category relationships
if (app.categories != null) {
for (final category in app.categories!) {
batch.insert(_appCategoriesTable, {
'package_name': app.packageName,
'category': category,
}, conflictAlgorithm: ConflictAlgorithm.ignore);
}
}
}
// Store metadata
final now = DateTime.now().millisecondsSinceEpoch;
batch.insert(_metadataTable, {
'key': 'last_sync',
'value': now.toString(),
'updated_at': now,
}, conflictAlgorithm: ConflictAlgorithm.replace);
batch.insert(_metadataTable, {
'key': 'repo_name',
'value': repository.name,
'updated_at': now,
}, conflictAlgorithm: ConflictAlgorithm.replace);
batch.insert(_metadataTable, {
'key': 'repo_description',
'value': repository.description,
'updated_at': now,
}, conflictAlgorithm: ConflictAlgorithm.replace);
// Commit all operations at once
await batch.commit(noResult: true);
}
/// Gets all apps from the database
/// Uses optimized batch loading to avoid N+1 query problem
Future<List<FDroidApp>> getAllApps() async {
final db = await database;
final appMaps = await db.rawQuery('''
SELECT a.*, r.url as repository_url FROM $_appsTable a
LEFT JOIN $_repositoriesTable r ON a.repository_id = r.id
''');
if (appMaps.isEmpty) return [];
// Batch load all categories and versions for all apps at once
final allCategories = await db.query(_appCategoriesTable);
final allVersions = await db.query(
_versionsTable,
orderBy: 'version_code DESC',
);
// Group by package name for efficient lookup
final categoriesByPackage = <String, List<String>>{};
for (final catRow in allCategories) {
final pkg = catRow['package_name'] as String;
final cat = catRow['category'] as String;
categoriesByPackage.putIfAbsent(pkg, () => []).add(cat);
}
final versionsByPackage = <String, List<Map<String, dynamic>>>{};
for (final verRow in allVersions) {
final pkg = verRow['package_name'] as String;
versionsByPackage.putIfAbsent(pkg, () => []).add(verRow);
}
// Build apps with pre-loaded data
final apps = <FDroidApp>[];
for (final appMap in appMaps) {
final packageName = appMap['package_name'] as String;
final app = _mapToAppWithData(
appMap,
categoriesByPackage[packageName] ?? [],
versionsByPackage[packageName] ?? [],
repositoryUrl: appMap['repository_url'] as String?,
);
apps.add(app);
}
return apps;
}
/// Gets latest apps ordered by added date
Future<List<FDroidApp>> getLatestApps({int limit = 50}) async {
final db = await database;
final appMaps = await db.rawQuery(
'''
SELECT a.*, r.url as repository_url FROM $_appsTable a
LEFT JOIN $_repositoriesTable r ON a.repository_id = r.id
WHERE a.added IS NOT NULL
ORDER BY a.added DESC
LIMIT ?
''',
[limit],
);
// Get package names from the limited result set
final packageNames = appMaps
.map((m) => m['package_name'] as String)
.toList();
if (packageNames.isEmpty) return [];
// Batch load categories and versions for these specific apps
final categoriesResults = await db.query(
_appCategoriesTable,
where:
'package_name IN (${List.filled(packageNames.length, '?').join(',')})',
whereArgs: packageNames,
);
final versionsResults = await db.query(
_versionsTable,
where:
'package_name IN (${List.filled(packageNames.length, '?').join(',')})',
whereArgs: packageNames,
orderBy: 'version_code DESC',
);
// Group by package name
final categoriesByPackage = <String, List<String>>{};
for (final catRow in categoriesResults) {
final pkg = catRow['package_name'] as String;
final cat = catRow['category'] as String;
categoriesByPackage.putIfAbsent(pkg, () => []).add(cat);
}
final versionsByPackage = <String, List<Map<String, dynamic>>>{};
for (final verRow in versionsResults) {
final pkg = verRow['package_name'] as String;
versionsByPackage.putIfAbsent(pkg, () => []).add(verRow);
}
final apps = <FDroidApp>[];
for (final appMap in appMaps) {
final packageName = appMap['package_name'] as String;
final app = _mapToAppWithData(
appMap,
categoriesByPackage[packageName] ?? [],
versionsByPackage[packageName] ?? [],
repositoryUrl: appMap['repository_url'] as String?,
);
apps.add(app);
}
return apps;
}
/// Gets all categories
Future<List<String>> getCategories() async {
final db = await database;
final results = await db.query(_categoriesTable, orderBy: 'category ASC');
return results.map((row) => row['category'] as String).toList();
}
/// Gets apps by category
Future<List<FDroidApp>> getAppsByCategory(String category) async {
final db = await database;
final appMaps = await db.rawQuery(
'''
SELECT a.*, r.url as repository_url FROM $_appsTable a
LEFT JOIN $_repositoriesTable r ON a.repository_id = r.id
INNER JOIN $_appCategoriesTable ac ON a.package_name = ac.package_name
WHERE ac.category = ?
ORDER BY a.name ASC
''',
[category],
);
if (appMaps.isEmpty) return [];
// Get package names from the result set
final packageNames = appMaps
.map((m) => m['package_name'] as String)
.toList();
// Batch load all categories and versions for these apps
final categoriesResults = await db.query(
_appCategoriesTable,
where:
'package_name IN (${List.filled(packageNames.length, '?').join(',')})',
whereArgs: packageNames,
);
final versionsResults = await db.query(
_versionsTable,
where:
'package_name IN (${List.filled(packageNames.length, '?').join(',')})',
whereArgs: packageNames,
orderBy: 'version_code DESC',
);
// Group by package name
final categoriesByPackage = <String, List<String>>{};
for (final catRow in categoriesResults) {
final pkg = catRow['package_name'] as String;
final cat = catRow['category'] as String;
categoriesByPackage.putIfAbsent(pkg, () => []).add(cat);
}
final versionsByPackage = <String, List<Map<String, dynamic>>>{};
for (final verRow in versionsResults) {
final pkg = verRow['package_name'] as String;
versionsByPackage.putIfAbsent(pkg, () => []).add(verRow);
}
final apps = <FDroidApp>[];
for (final appMap in appMaps) {
final packageName = appMap['package_name'] as String;
final app = _mapToAppWithData(
appMap,
categoriesByPackage[packageName] ?? [],
versionsByPackage[packageName] ?? [],
repositoryUrl: appMap['repository_url'] as String?,
);
apps.add(app);
}
return apps;
}
/// Searches apps by repository URL
Future<List<FDroidApp>> searchAppsByRepository(
String query,
String repositoryUrl,
) async {
final db = await database;
// Get repository ID by URL
final repoResults = await db.query(
_repositoriesTable,
where: 'url = ?',
whereArgs: [repositoryUrl],
);
if (repoResults.isEmpty) {
return []; // Repository not found in database
}
final repositoryId = repoResults.first['id'] as int;
final searchTerm = '%${query.toLowerCase()}%';
final exactQuery = query.toLowerCase();
final startsWithTerm = '${query.toLowerCase()}%';
final normalizedQuery = query.toLowerCase().replaceAll('-', '');
final normalizedTerm = '%$normalizedQuery%';
// Search apps from this specific repository with weighted scoring
final appMaps = await db.rawQuery(
'''
SELECT DISTINCT a.*, r.url as repository_url,
CASE
WHEN LOWER(a.name) = ? THEN 10000
WHEN REPLACE(LOWER(a.name), '-', '') = ? THEN 9000
WHEN LOWER(a.name) LIKE ? THEN 5000
WHEN REPLACE(LOWER(a.name), '-', '') LIKE ? THEN 3000
WHEN LOWER(a.name) LIKE ? THEN 1000
WHEN LOWER(a.summary) LIKE ? THEN 100
WHEN REPLACE(LOWER(a.summary), '-', '') LIKE ? THEN 75
WHEN LOWER(a.description) LIKE ? THEN 50
WHEN REPLACE(LOWER(a.description), '-', '') LIKE ? THEN 40
WHEN EXISTS (
SELECT 1 FROM $_appCategoriesTable ac
WHERE ac.package_name = a.package_name
AND LOWER(ac.category) LIKE ?
) THEN 25
WHEN LOWER(a.package_name) LIKE ? THEN 10
WHEN REPLACE(LOWER(a.package_name), '-', '') LIKE ? THEN 8
ELSE 1
END as search_score
FROM $_appsTable a
LEFT JOIN $_repositoriesTable r ON a.repository_id = r.id
LEFT JOIN $_appCategoriesTable ac ON a.package_name = ac.package_name
WHERE a.repository_id = ?
AND (LOWER(a.name) LIKE ?
OR REPLACE(LOWER(a.name), '-', '') LIKE ?
OR LOWER(a.summary) LIKE ?
OR REPLACE(LOWER(a.summary), '-', '') LIKE ?
OR LOWER(a.description) LIKE ?
OR REPLACE(LOWER(a.description), '-', '') LIKE ?
OR LOWER(a.package_name) LIKE ?
OR REPLACE(LOWER(a.package_name), '-', '') LIKE ?
OR LOWER(ac.category) LIKE ?)
ORDER BY search_score DESC, a.name ASC
''',
[
exactQuery,
normalizedQuery,
startsWithTerm,
normalizedTerm,
searchTerm,
searchTerm,
normalizedTerm,
searchTerm,
normalizedTerm,
searchTerm,
searchTerm,
normalizedTerm,
repositoryId,
searchTerm,
normalizedTerm,
searchTerm,
normalizedTerm,
searchTerm,
normalizedTerm,
searchTerm,
normalizedTerm,
searchTerm,
],
);
if (appMaps.isEmpty) return [];
// Get package names from the result set
final packageNames = appMaps
.map((m) => m['package_name'] as String)
.toList();
// Batch load all categories and versions for these apps
final categoriesResults = await db.query(
_appCategoriesTable,
where:
'package_name IN (${List.filled(packageNames.length, '?').join(',')})',
whereArgs: packageNames,
);
final versionsResults = await db.query(
_versionsTable,
where:
'package_name IN (${List.filled(packageNames.length, '?').join(',')})',
whereArgs: packageNames,
orderBy: 'version_code DESC',
);
// Group by package name
final categoriesByPackage = <String, List<String>>{};
for (final catRow in categoriesResults) {
final pkg = catRow['package_name'] as String;
final cat = catRow['category'] as String;
categoriesByPackage.putIfAbsent(pkg, () => []).add(cat);
}
final versionsByPackage = <String, List<Map<String, dynamic>>>{};
for (final verRow in versionsResults) {
final pkg = verRow['package_name'] as String;
versionsByPackage.putIfAbsent(pkg, () => []).add(verRow);
}
final apps = <FDroidApp>[];
for (final appMap in appMaps) {
final packageName = appMap['package_name'] as String;
final app = _mapToAppWithData(
appMap,
categoriesByPackage[packageName] ?? [],
versionsByPackage[packageName] ?? [],
repositoryUrl: appMap['repository_url'] as String?,
);
apps.add(app);
}
return apps;
}
/// Searches apps by name, summary, description, or package name
Future<List<FDroidApp>> searchApps(String query) async {
final db = await database;
final searchTerm = '%${query.toLowerCase()}%';
final exactQuery = query.toLowerCase();
final startsWithTerm = '${query.toLowerCase()}%';
final normalizedQuery = query.toLowerCase().replaceAll('-', '');
final normalizedTerm = '%$normalizedQuery%';
final appMaps = await db.rawQuery(
'''
SELECT DISTINCT a.*, r.url as repository_url,
CASE
WHEN LOWER(a.name) = ? THEN 10000
WHEN REPLACE(LOWER(a.name), '-', '') = ? THEN 9000
WHEN LOWER(a.name) LIKE ? THEN 5000
WHEN REPLACE(LOWER(a.name), '-', '') LIKE ? THEN 3000
WHEN LOWER(a.name) LIKE ? THEN 1000
WHEN LOWER(a.summary) LIKE ? THEN 100
WHEN REPLACE(LOWER(a.summary), '-', '') LIKE ? THEN 75
WHEN LOWER(a.description) LIKE ? THEN 50
WHEN REPLACE(LOWER(a.description), '-', '') LIKE ? THEN 40
WHEN EXISTS (
SELECT 1 FROM $_appCategoriesTable ac
WHERE ac.package_name = a.package_name
AND LOWER(ac.category) LIKE ?
) THEN 25
WHEN LOWER(a.package_name) LIKE ? THEN 10
WHEN REPLACE(LOWER(a.package_name), '-', '') LIKE ? THEN 8
ELSE 1
END as search_score
FROM $_appsTable a
LEFT JOIN $_repositoriesTable r ON a.repository_id = r.id
LEFT JOIN $_appCategoriesTable ac ON a.package_name = ac.package_name
WHERE LOWER(a.name) LIKE ?
OR REPLACE(LOWER(a.name), '-', '') LIKE ?
OR LOWER(a.summary) LIKE ?
OR REPLACE(LOWER(a.summary), '-', '') LIKE ?
OR LOWER(a.description) LIKE ?
OR REPLACE(LOWER(a.description), '-', '') LIKE ?
OR LOWER(a.package_name) LIKE ?
OR REPLACE(LOWER(a.package_name), '-', '') LIKE ?
OR LOWER(ac.category) LIKE ?
ORDER BY search_score DESC, a.name ASC
''',
[
exactQuery,
normalizedQuery,
startsWithTerm,
normalizedTerm,
searchTerm,
searchTerm,
normalizedTerm,
searchTerm,
normalizedTerm,
searchTerm,
searchTerm,
normalizedTerm,
searchTerm,
normalizedTerm,
searchTerm,
normalizedTerm,
searchTerm,
normalizedTerm,
searchTerm,
searchTerm,
],
);
if (appMaps.isEmpty) return [];
// Get package names from the result set
final packageNames = appMaps
.map((m) => m['package_name'] as String)
.toList();
// Batch load all categories and versions for these apps
final categoriesResults = await db.query(
_appCategoriesTable,
where:
'package_name IN (${List.filled(packageNames.length, '?').join(',')})',
whereArgs: packageNames,
);
final versionsResults = await db.query(
_versionsTable,
where:
'package_name IN (${List.filled(packageNames.length, '?').join(',')})',
whereArgs: packageNames,
orderBy: 'version_code DESC',
);
// Group by package name
final categoriesByPackage = <String, List<String>>{};
for (final catRow in categoriesResults) {
final pkg = catRow['package_name'] as String;
final cat = catRow['category'] as String;
categoriesByPackage.putIfAbsent(pkg, () => []).add(cat);
}
final versionsByPackage = <String, List<Map<String, dynamic>>>{};
for (final verRow in versionsResults) {
final pkg = verRow['package_name'] as String;
versionsByPackage.putIfAbsent(pkg, () => []).add(verRow);
}
final apps = <FDroidApp>[];
for (final appMap in appMaps) {
final packageName = appMap['package_name'] as String;
final app = _mapToAppWithData(
appMap,
categoriesByPackage[packageName] ?? [],
versionsByPackage[packageName] ?? [],
repositoryUrl: appMap['repository_url'] as String?,
);
apps.add(app);
}
return apps;
}
/// Gets a specific app by package name
Future<FDroidApp?> getApp(String packageName) async {
final db = await database;
final results = await db.rawQuery(
'''
SELECT a.*, r.url as repository_url FROM $_appsTable a
LEFT JOIN $_repositoriesTable r ON a.repository_id = r.id
WHERE a.package_name = ?
''',
[packageName],
);
if (results.isEmpty) return null;
return await _mapToApp(results.first);
}
/// Converts a database row to an FDroidApp with pre-loaded data
/// This version accepts pre-loaded categories and versions to avoid N+1 queries
FDroidApp _mapToAppWithData(
Map<String, dynamic> appMap,
List<String> categories,
List<Map<String, dynamic>> versionMaps, {
String? repositoryUrl,
}) {
final packages = <String, FDroidVersion>{};
for (final versionMap in versionMaps) {
final version = FDroidVersion(
versionCode: versionMap['version_code'] as int,
versionName: versionMap['version_name'] as String,
size: versionMap['size'] as int,
minSdkVersion: versionMap['min_sdk_version'] as String?,
targetSdkVersion: versionMap['target_sdk_version'] as String?,
maxSdkVersion: versionMap['max_sdk_version'] as String?,
added: DateTime.fromMillisecondsSinceEpoch(versionMap['added'] as int),
apkName: versionMap['apk_name'] as String,
hash: versionMap['hash'] as String,
hashType: versionMap['hash_type'] as String,
sig: versionMap['sig'] as String?,
permissions: versionMap['permissions'] != null
? List<String>.from(jsonDecode(versionMap['permissions'] as String))
: null,
features: versionMap['features'] != null
? List<String>.from(jsonDecode(versionMap['features'] as String))
: null,
nativecode: versionMap['nativecode'] != null
? List<String>.from(jsonDecode(versionMap['nativecode'] as String))
: null,
whatsNew: versionMap['whats_new'] as String?,
);
packages[version.versionCode.toString()] = version;
}
return FDroidApp(
packageName: appMap['package_name'] as String,
name: appMap['name'] as String,
summary: appMap['summary'] as String,
description: appMap['description'] as String,
icon: appMap['icon'] as String?,
authorName: appMap['author_name'] as String?,
authorEmail: appMap['author_email'] as String?,
authorWebSite: appMap['author_website'] as String?,
webSite: appMap['website'] as String?,
issueTracker: appMap['issue_tracker'] as String?,
sourceCode: appMap['source_code'] as String?,
changelog: appMap['changelog'] as String?,
donate: appMap['donate'] as String?,
bitcoin: appMap['bitcoin'] as String?,
flattrID: appMap['flattr_id'] as String?,
license: appMap['license'] as String,
categories: categories.isEmpty ? null : categories,
packages: packages.isEmpty ? null : packages,
suggestedVersionName: appMap['suggested_version_name'] as String?,
suggestedVersionCode: appMap['suggested_version_code'] as int?,
added: appMap['added'] != null
? DateTime.fromMillisecondsSinceEpoch(appMap['added'] as int)
: null,
lastUpdated: appMap['last_updated'] != null
? DateTime.fromMillisecondsSinceEpoch(appMap['last_updated'] as int)
: null,
repositoryUrl: repositoryUrl ?? 'https://f-droid.org/repo',
);
}
/// Converts a database row to an FDroidApp (for single app queries)
/// This version makes individual queries for categories and versions
Future<FDroidApp> _mapToApp(Map<String, dynamic> appMap) async {
final packageName = appMap['package_name'] as String;
// Get categories
final db = await database;
final categoryResults = await db.query(
_appCategoriesTable,
where: 'package_name = ?',
whereArgs: [packageName],
);
final categories = categoryResults
.map((row) => row['category'] as String)
.toList();
// Get versions
final versionResults = await db.query(
_versionsTable,
where: 'package_name = ?',
whereArgs: [packageName],
orderBy: 'version_code DESC',
);
return _mapToAppWithData(
appMap,
categories,
versionResults,
repositoryUrl: appMap['repository_url'] as String?,
);
}
/// Closes the database
Future<void> close() async {
final db = _database;
if (db != null) {
await db.close();
_database = null;
}
}
/// Clears all database data
Future<void> clearAll() async {
final db = await database;
await db.transaction((txn) async {
await txn.delete(_appCategoriesTable);
await txn.delete(_versionsTable);
await txn.delete(_appsTable);
await txn.delete(_categoriesTable);
await txn.delete(_metadataTable);
});
}
// Repository management methods
/// Adds a new repository
Future<int> addRepository(String name, String url) async {
final db = await database;
return await db.insert(_repositoriesTable, {
'name': name,
'url': url,
'is_enabled': 1,
'added_at': DateTime.now().millisecondsSinceEpoch,
});
}
/// Gets all repositories
Future<List<Map<String, dynamic>>> getAllRepositories() async {
final db = await database;
return await db.query(_repositoriesTable, orderBy: 'added_at DESC');
}
/// Updates a repository
Future<int> updateRepository(
int id,
String name,
String url,
bool isEnabled,
) async {
final db = await database;
return await db.update(
_repositoriesTable,
{'name': name, 'url': url, 'is_enabled': isEnabled ? 1 : 0},
where: 'id = ?',
whereArgs: [id],
);
}
/// Deletes a repository
Future<int> deleteRepository(int id) async {
final db = await database;
return await db.delete(
_repositoriesTable,
where: 'id = ?',
whereArgs: [id],
);
}
/// Updates the last synced timestamp for a repository
Future<int> updateRepositoryLastSynced(int id) async {
final db = await database;
return await db.update(
_repositoriesTable,
{'last_synced_at': DateTime.now().millisecondsSinceEpoch},
where: 'id = ?',
whereArgs: [id],
);
}
/// Gets enabled repositories
Future<List<Map<String, dynamic>>> getEnabledRepositories() async {
final db = await database;
return await db.query(
_repositoriesTable,
where: 'is_enabled = 1',
orderBy: 'added_at DESC',
);
}
}