From 704234c9dfcbf860324e35ca52285e9d1bfdcfaf Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 16 Oct 2023 18:16:48 -0300 Subject: [PATCH 01/33] [db] allow setting a preferred repo per app --- .../2.json | 1094 +++++++++++++++++ .../java/org/fdroid/database/AppDaoTest.kt | 29 + .../org/fdroid/database/AppListItemsTest.kt | 52 + .../fdroid/database/AppOverviewItemsTest.kt | 57 + .../dbTest/java/org/fdroid/database/DbTest.kt | 3 +- .../org/fdroid/database/RepositoryDaoTest.kt | 4 +- .../java/org/fdroid/database/VersionTest.kt | 90 +- .../main/java/org/fdroid/database/AppDao.kt | 37 +- .../main/java/org/fdroid/database/AppPrefs.kt | 1 + .../org/fdroid/database/FDroidDatabase.kt | 4 +- .../java/org/fdroid/database/VersionDao.kt | 15 +- 11 files changed, 1327 insertions(+), 59 deletions(-) create mode 100644 libs/database/schemas/org.fdroid.database.FDroidDatabaseInt/2.json diff --git a/libs/database/schemas/org.fdroid.database.FDroidDatabaseInt/2.json b/libs/database/schemas/org.fdroid.database.FDroidDatabaseInt/2.json new file mode 100644 index 000000000..ecb4afb99 --- /dev/null +++ b/libs/database/schemas/org.fdroid.database.FDroidDatabaseInt/2.json @@ -0,0 +1,1094 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "6f1a1580d37e51593441ffa87a858914", + "entities": [ + { + "tableName": "CoreRepository", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT, `address` TEXT NOT NULL, `webBaseUrl` TEXT, `timestamp` INTEGER NOT NULL, `version` INTEGER, `formatVersion` TEXT, `maxAge` INTEGER, `description` TEXT NOT NULL, `certificate` TEXT)", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "webBaseUrl", + "columnName": "webBaseUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "formatVersion", + "columnName": "formatVersion", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxAge", + "columnName": "maxAge", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate", + "columnName": "certificate", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "repoId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Mirror", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `url` TEXT NOT NULL, `location` TEXT, PRIMARY KEY(`repoId`, `url`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "url" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CoreRepository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId" + ], + "referencedColumns": [ + "repoId" + ] + } + ] + }, + { + "tableName": "AntiFeature", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `id` TEXT NOT NULL, `icon` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, PRIMARY KEY(`repoId`, `id`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CoreRepository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId" + ], + "referencedColumns": [ + "repoId" + ] + } + ] + }, + { + "tableName": "Category", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `id` TEXT NOT NULL, `icon` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, PRIMARY KEY(`repoId`, `id`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CoreRepository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId" + ], + "referencedColumns": [ + "repoId" + ] + } + ] + }, + { + "tableName": "ReleaseChannel", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `id` TEXT NOT NULL, `icon` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, PRIMARY KEY(`repoId`, `id`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CoreRepository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId" + ], + "referencedColumns": [ + "repoId" + ] + } + ] + }, + { + "tableName": "RepositoryPreferences", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `lastUpdated` INTEGER, `lastETag` TEXT, `userMirrors` TEXT, `disabledMirrors` TEXT, `username` TEXT, `password` TEXT, PRIMARY KEY(`repoId`))", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastETag", + "columnName": "lastETag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userMirrors", + "columnName": "userMirrors", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "disabledMirrors", + "columnName": "disabledMirrors", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AppMetadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `added` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `name` TEXT, `summary` TEXT, `description` TEXT, `localizedName` TEXT, `localizedSummary` TEXT, `webSite` TEXT, `changelog` TEXT, `license` TEXT, `sourceCode` TEXT, `issueTracker` TEXT, `translation` TEXT, `preferredSigner` TEXT, `video` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorWebSite` TEXT, `authorPhone` TEXT, `donate` TEXT, `liberapayID` TEXT, `liberapay` TEXT, `openCollective` TEXT, `bitcoin` TEXT, `litecoin` TEXT, `flattrID` TEXT, `categories` TEXT, `isCompatible` INTEGER NOT NULL, PRIMARY KEY(`repoId`, `packageName`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "added", + "columnName": "added", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "summary", + "columnName": "summary", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localizedName", + "columnName": "localizedName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localizedSummary", + "columnName": "localizedSummary", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "webSite", + "columnName": "webSite", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "changelog", + "columnName": "changelog", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceCode", + "columnName": "sourceCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "issueTracker", + "columnName": "issueTracker", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "translation", + "columnName": "translation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "preferredSigner", + "columnName": "preferredSigner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "video", + "columnName": "video", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorWebSite", + "columnName": "authorWebSite", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorPhone", + "columnName": "authorPhone", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "donate", + "columnName": "donate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liberapayID", + "columnName": "liberapayID", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liberapay", + "columnName": "liberapay", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "openCollective", + "columnName": "openCollective", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitcoin", + "columnName": "bitcoin", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "litecoin", + "columnName": "litecoin", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "flattrID", + "columnName": "flattrID", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isCompatible", + "columnName": "isCompatible", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "packageName" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CoreRepository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId" + ], + "referencedColumns": [ + "repoId" + ] + } + ] + }, + { + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "AppMetadata", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_BEFORE_UPDATE BEFORE UPDATE ON `AppMetadata` BEGIN DELETE FROM `AppMetadataFts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_BEFORE_DELETE BEFORE DELETE ON `AppMetadata` BEGIN DELETE FROM `AppMetadataFts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_AFTER_UPDATE AFTER UPDATE ON `AppMetadata` BEGIN INSERT INTO `AppMetadataFts`(`docid`, `repoId`, `packageName`, `localizedName`, `localizedSummary`) VALUES (NEW.`rowid`, NEW.`repoId`, NEW.`packageName`, NEW.`localizedName`, NEW.`localizedSummary`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_AFTER_INSERT AFTER INSERT ON `AppMetadata` BEGIN INSERT INTO `AppMetadataFts`(`docid`, `repoId`, `packageName`, `localizedName`, `localizedSummary`) VALUES (NEW.`rowid`, NEW.`repoId`, NEW.`packageName`, NEW.`localizedName`, NEW.`localizedSummary`); END" + ], + "tableName": "AppMetadataFts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `localizedName` TEXT, `localizedSummary` TEXT, content=`AppMetadata`)", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "localizedName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "summary", + "columnName": "localizedSummary", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "LocalizedFile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `type` TEXT NOT NULL, `locale` TEXT NOT NULL, `name` TEXT NOT NULL, `sha256` TEXT, `size` INTEGER, `ipfsCidV1` TEXT, PRIMARY KEY(`repoId`, `packageName`, `type`, `locale`), FOREIGN KEY(`repoId`, `packageName`) REFERENCES `AppMetadata`(`repoId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sha256", + "columnName": "sha256", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ipfsCidV1", + "columnName": "ipfsCidV1", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "packageName", + "type", + "locale" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AppMetadata", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId", + "packageName" + ], + "referencedColumns": [ + "repoId", + "packageName" + ] + } + ] + }, + { + "tableName": "LocalizedFileList", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `type` TEXT NOT NULL, `locale` TEXT NOT NULL, `name` TEXT NOT NULL, `sha256` TEXT, `size` INTEGER, `ipfsCidV1` TEXT, PRIMARY KEY(`repoId`, `packageName`, `type`, `locale`, `name`), FOREIGN KEY(`repoId`, `packageName`) REFERENCES `AppMetadata`(`repoId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sha256", + "columnName": "sha256", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ipfsCidV1", + "columnName": "ipfsCidV1", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "packageName", + "type", + "locale", + "name" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AppMetadata", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId", + "packageName" + ], + "referencedColumns": [ + "repoId", + "packageName" + ] + } + ] + }, + { + "tableName": "Version", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `versionId` TEXT NOT NULL, `added` INTEGER NOT NULL, `releaseChannels` TEXT, `antiFeatures` TEXT, `whatsNew` TEXT, `isCompatible` INTEGER NOT NULL, `file_name` TEXT NOT NULL, `file_sha256` TEXT NOT NULL, `file_size` INTEGER, `file_ipfsCidV1` TEXT, `src_name` TEXT, `src_sha256` TEXT, `src_size` INTEGER, `src_ipfsCidV1` TEXT, `manifest_versionName` TEXT NOT NULL, `manifest_versionCode` INTEGER NOT NULL, `manifest_maxSdkVersion` INTEGER, `manifest_nativecode` TEXT, `manifest_features` TEXT, `manifest_usesSdk_minSdkVersion` INTEGER, `manifest_usesSdk_targetSdkVersion` INTEGER, `manifest_signer_sha256` TEXT, `manifest_signer_hasMultipleSigners` INTEGER, PRIMARY KEY(`repoId`, `packageName`, `versionId`), FOREIGN KEY(`repoId`, `packageName`) REFERENCES `AppMetadata`(`repoId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionId", + "columnName": "versionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "added", + "columnName": "added", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseChannels", + "columnName": "releaseChannels", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "antiFeatures", + "columnName": "antiFeatures", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "whatsNew", + "columnName": "whatsNew", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isCompatible", + "columnName": "isCompatible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "file.name", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "file.sha256", + "columnName": "file_sha256", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "file.size", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "file.ipfsCidV1", + "columnName": "file_ipfsCidV1", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "src.name", + "columnName": "src_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "src.sha256", + "columnName": "src_sha256", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "src.size", + "columnName": "src_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "src.ipfsCidV1", + "columnName": "src_ipfsCidV1", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "manifest.versionName", + "columnName": "manifest_versionName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "manifest.versionCode", + "columnName": "manifest_versionCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "manifest.maxSdkVersion", + "columnName": "manifest_maxSdkVersion", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "manifest.nativecode", + "columnName": "manifest_nativecode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "manifest.features", + "columnName": "manifest_features", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "manifest.usesSdk.minSdkVersion", + "columnName": "manifest_usesSdk_minSdkVersion", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "manifest.usesSdk.targetSdkVersion", + "columnName": "manifest_usesSdk_targetSdkVersion", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "manifest.signer.sha256", + "columnName": "manifest_signer_sha256", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "manifest.signer.hasMultipleSigners", + "columnName": "manifest_signer_hasMultipleSigners", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "packageName", + "versionId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AppMetadata", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId", + "packageName" + ], + "referencedColumns": [ + "repoId", + "packageName" + ] + } + ] + }, + { + "tableName": "VersionedString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `versionId` TEXT NOT NULL, `type` TEXT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER, PRIMARY KEY(`repoId`, `packageName`, `versionId`, `type`, `name`), FOREIGN KEY(`repoId`, `packageName`, `versionId`) REFERENCES `Version`(`repoId`, `packageName`, `versionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionId", + "columnName": "versionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "packageName", + "versionId", + "type", + "name" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Version", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId", + "packageName", + "versionId" + ], + "referencedColumns": [ + "repoId", + "packageName", + "versionId" + ] + } + ] + }, + { + "tableName": "AppPrefs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `ignoreVersionCodeUpdate` INTEGER NOT NULL, `preferredRepoId` INTEGER, `appPrefReleaseChannels` TEXT, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ignoreVersionCodeUpdate", + "columnName": "ignoreVersionCodeUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "preferredRepoId", + "columnName": "preferredRepoId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "appPrefReleaseChannels", + "columnName": "appPrefReleaseChannels", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "LocalizedIcon", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM LocalizedFile WHERE type='icon'" + }, + { + "viewName": "HighestVersion", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT repoId, packageName, antiFeatures FROM Version\n GROUP BY repoId, packageName HAVING MAX(manifest_versionCode)" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6f1a1580d37e51593441ffa87a858914')" + ] + } +} \ No newline at end of file diff --git a/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt index d825b6a83..5c3d0aa24 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt @@ -62,6 +62,28 @@ internal class AppDaoTest : AppTest() { assertEquals(0, appDao.countLocalizedFileLists()) } + @Test + fun testAppRepoPref() { + // insert same app into three repos (repoId1 has highest weight) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId1, packageName, app1, locales) + appDao.insert(repoId2, packageName, app2, locales) + appDao.insert(repoId3, packageName, app3, locales) + + // app from repo with highest weight is returned, if no prefs are set + assertEquals(app1, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort()) + + // prefer repo3 for this app + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId3)) + assertEquals(app3, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort()) + + // prefer repo1 for this app + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId1)) + assertEquals(app1, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort()) + } + @Test fun testGetSameAppFromTwoReposOneDisabled() { // insert same app into two repos (repoId2 has highest weight) @@ -149,6 +171,13 @@ internal class AppDaoTest : AppTest() { assertEquals(3, appDao.getNumberOfAppsInCategory("A")) assertEquals(2, appDao.getNumberOfAppsInCategory("B")) assertEquals(0, appDao.getNumberOfAppsInCategory("C")) + + // app1 as a variant of app2 in another repo will show one more app in B + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId2, packageName2, app1, locales) + assertEquals(3, appDao.getNumberOfAppsInCategory("A")) + assertEquals(3, appDao.getNumberOfAppsInCategory("B")) + assertEquals(0, appDao.getNumberOfAppsInCategory("C")) } @Test diff --git a/libs/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt index a989cc401..b7dce5948 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt @@ -451,6 +451,48 @@ internal class AppListItemsTest : AppTest() { } } + @Test + fun testFromRepoFromAppPrefs() { + // insert same app into three repos (repoId1 has highest weight) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId2, packageName, app2, locales) + appDao.insert(repoId1, packageName, app1, locales) + appDao.insert(repoId3, packageName, app3, locales) + + // app from repo1 with highest weight gets returned + getItems { apps -> + assertEquals(1, apps.size) + assertEquals(packageName, apps[0].packageName) + assertEquals(app1, apps[0]) + } + + // prefer repo3 for this app + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId3)) + getItems { apps -> + assertEquals(1, apps.size) + assertEquals(packageName, apps[0].packageName) + assertEquals(app3, apps[0]) + } + + // prefer repo2 for this app + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId2)) + getItems { apps -> + assertEquals(1, apps.size) + assertEquals(packageName, apps[0].packageName) + assertEquals(app2, apps[0]) + } + + // prefer repo1 for this app + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId1)) + getItems { apps -> + assertEquals(1, apps.size) + assertEquals(packageName, apps[0].packageName) + assertEquals(app1, apps[0]) + } + } + @Test fun testOnlyFromGivenCategories() { // insert three apps @@ -476,6 +518,16 @@ internal class AppListItemsTest : AppTest() { ).forEach { apps -> assertEquals(0, apps.size) } + + // we'll add app1 as a variant of app2, so it should be in category B as well + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId2, packageName2, app1, locales) + listOf( + appDao.getAppListItemsByName("B").getOrFail(), + appDao.getAppListItemsByLastUpdated("B").getOrFail(), + ).forEach { apps -> + assertEquals(3, apps.size) // all apps are in B now + } } @Test diff --git a/libs/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt index ad7aa0c72..25fc58f1a 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt @@ -159,6 +159,63 @@ internal class AppOverviewItemsTest : AppTest() { } } + @Test + fun testGetByRepoPref() { + // insert same app into three repos (repoId1 has highest weight) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId1, packageName, app1, locales) + appDao.insert(repoId2, packageName, app2, locales) + appDao.insert(repoId3, packageName, app3, locales) + + // app is returned correctly from repo1 + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app1, apps[0]) + } + appDao.getAppOverviewItems("A").getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app1, apps[0]) + } + + // prefer repo3 for this app + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId3)) + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app3, apps[0]) + } + appDao.getAppOverviewItems("B").getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app3, apps[0]) + } + + // prefer repo2 for this app + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId2)) + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app2, apps[0]) + } + appDao.getAppOverviewItems("A").getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app2, apps[0]) + } + appDao.getAppOverviewItems("B").getOrFail().let { apps -> + assertEquals(0, apps.size) // app2 is not in category B + } + + // prefer repo1 for this app + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId1)) + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app1, apps[0]) + } + appDao.getAppOverviewItems("A").getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app1, apps[0]) + } + } + @Test fun testSortOrder() { // insert two apps with one version each diff --git a/libs/database/src/dbTest/java/org/fdroid/database/DbTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/DbTest.kt index 63baa388c..ddcec7551 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/DbTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/DbTest.kt @@ -10,6 +10,7 @@ import io.mockk.every import io.mockk.mockkObject import kotlinx.coroutines.Dispatchers import org.fdroid.database.TestUtils.assertRepoEquals +import org.fdroid.database.TestUtils.getOrFail import org.fdroid.database.TestUtils.toMetadataV2 import org.fdroid.database.TestUtils.toPackageVersionV2 import org.fdroid.index.v1.IndexV1StreamProcessor @@ -111,7 +112,7 @@ internal abstract class DbTest { packageV2.metadata, appDao.getApp(repoId, packageName)?.toMetadataV2()?.sort() ) - val versions = versionDao.getAppVersions(repoId, packageName).map { + val versions = versionDao.getAppVersions(repoId, packageName).getOrFail().map { it.toPackageVersionV2() }.associateBy { it.file.sha256 } assertEquals(packageV2.versions.size, versions.size, "number of versions") diff --git a/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt index f041fd2a7..2b284beb6 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt @@ -255,7 +255,7 @@ internal class RepositoryDaoTest : DbTest() { // data is there as expected assertEquals(1, repoDao.getRepositories().size) assertEquals(1, appDao.getAppMetadata().size) - assertEquals(1, versionDao.getAppVersions(repoId, packageName).size) + assertEquals(1, versionDao.getAppVersions(repoId, packageName).getOrFail().size) assertTrue(versionDao.getVersionedStrings(repoId, packageName).isNotEmpty()) // clearing the repo removes apps and versions @@ -264,7 +264,7 @@ internal class RepositoryDaoTest : DbTest() { assertEquals(0, appDao.countApps()) assertEquals(0, appDao.countLocalizedFiles()) assertEquals(0, appDao.countLocalizedFileLists()) - assertEquals(0, versionDao.getAppVersions(repoId, packageName).size) + assertEquals(0, versionDao.getAppVersions(repoId, packageName).getOrFail().size) assertEquals(0, versionDao.getVersionedStrings(repoId, packageName).size) // preferences are not touched by clearing assertEquals(repositoryPreferences, repoDao.getRepositoryPreferences(repoId)) diff --git a/libs/database/src/dbTest/java/org/fdroid/database/VersionTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/VersionTest.kt index 8599948a1..244dcf79d 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/VersionTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/VersionTest.kt @@ -35,6 +35,22 @@ internal class VersionTest : DbTest() { versionId2 to packageVersion2, ) + private fun getAppVersion1(repoId: Long): AppVersion { + val version = getVersion1(repoId) + return AppVersion( + version = version, + versionedStrings = packageVersion1.manifest.getVersionedStrings(version), + ) + } + + private fun getAppVersion2(repoId: Long): AppVersion { + val version = getVersion2(repoId) + return AppVersion( + version = version, + versionedStrings = packageVersion2.manifest.getVersionedStrings(version), + ) + } + private fun getVersion1(repoId: Long) = packageVersion1.toVersion(repoId, packageName, versionId1, isCompatible1) @@ -55,25 +71,22 @@ internal class VersionTest : DbTest() { appDao.insert(repoId, packageName, getRandomMetadataV2()) versionDao.insert(repoId, packageName, versionId1, packageVersion1, isCompatible1) - val appVersions = versionDao.getAppVersions(repoId, packageName) + val appVersions = versionDao.getAppVersions(repoId, packageName).getOrFail() assertEquals(1, appVersions.size) - val appVersion = appVersions[0] - assertEquals(versionId1, appVersion.version.versionId) - assertEquals(getVersion1(repoId), appVersion.version) - val manifest = packageVersion1.manifest - assertEquals(manifest.usesPermission.toSet(), appVersion.usesPermission.toSet()) - assertEquals(manifest.usesPermissionSdk23.toSet(), appVersion.usesPermissionSdk23.toSet()) - assertEquals( - manifest.features.map { it.name }.toSet(), - appVersion.version.manifest.features?.toSet() - ) + assertEquals(getAppVersion1(repoId), appVersions[0]) + val manifest = packageVersion1.manifest val versionedStrings = versionDao.getVersionedStrings(repoId, packageName) val expectedSize = manifest.usesPermission.size + manifest.usesPermissionSdk23.size assertEquals(expectedSize, versionedStrings.size) + // getting version by repo produces same result + val versionsByRepo = versionDao.getAppVersions(repoId, packageName).getOrFail() + assertEquals(1, versionsByRepo.size) + assertEquals(getAppVersion1(repoId), versionsByRepo[0]) + versionDao.deleteAppVersion(repoId, packageName, versionId1) - assertEquals(0, versionDao.getAppVersions(repoId, packageName).size) + assertEquals(0, versionDao.getAppVersions(repoId, packageName).getOrFail().size) assertEquals(0, versionDao.getVersionedStrings(repoId, packageName).size) } @@ -86,39 +99,29 @@ internal class VersionTest : DbTest() { versionDao.insert(repoId, packageName, versionId2, packageVersion2, isCompatible2) // get app versions from DB and assign them correctly - val appVersions = versionDao.getAppVersions(packageName).getOrFail() - assertEquals(2, appVersions.size) - val appVersion = if (versionId1 == appVersions[0].version.versionId) { - appVersions[0] - } else appVersions[1] - val appVersion2 = if (versionId2 == appVersions[0].version.versionId) { - appVersions[0] - } else appVersions[1] + listOf( + versionDao.getAppVersions(packageName).getOrFail(), + versionDao.getAppVersions(repoId, packageName).getOrFail(), + ).forEach { appVersions -> + assertEquals(2, appVersions.size) + val appVersion = if (versionId1 == appVersions[0].version.versionId) { + appVersions[0] + } else appVersions[1] + val appVersion2 = if (versionId2 == appVersions[0].version.versionId) { + appVersions[0] + } else appVersions[1] - // check first version matches - assertEquals(getVersion1(repoId), appVersion.version) - val manifest = packageVersion1.manifest - assertEquals(manifest.usesPermission.toSet(), appVersion.usesPermission.toSet()) - assertEquals(manifest.usesPermissionSdk23.toSet(), appVersion.usesPermissionSdk23.toSet()) - assertEquals( - manifest.features.map { it.name }.toSet(), - appVersion.version.manifest.features?.toSet() - ) + // check first version matches + assertEquals(getAppVersion1(repoId), appVersion) - // check second version matches - assertEquals(getVersion2(repoId), appVersion2.version) - val manifest2 = packageVersion2.manifest - assertEquals(manifest2.usesPermission.toSet(), appVersion2.usesPermission.toSet()) - assertEquals(manifest2.usesPermissionSdk23.toSet(), - appVersion2.usesPermissionSdk23.toSet()) - assertEquals( - manifest.features.map { it.name }.toSet(), - appVersion.version.manifest.features?.toSet() - ) + // check second version matches + assertEquals(getAppVersion2(repoId), appVersion2) + } // delete app and check that all associated data also gets deleted appDao.deleteAppMetadata(repoId, packageName) - assertEquals(0, versionDao.getAppVersions(repoId, packageName).size) + assertEquals(0, versionDao.getAppVersions(packageName).getOrFail().size) + assertEquals(0, versionDao.getAppVersions(repoId, packageName).getOrFail().size) assertEquals(0, versionDao.getVersionedStrings(repoId, packageName).size) } @@ -138,6 +141,10 @@ internal class VersionTest : DbTest() { assertEquals(3, versionDao.getAppVersions(packageName).getOrFail().size) assertEquals(3, versionDao.getVersions(listOf(packageName)).size) + // query by repo only returns the versions from each repo + assertEquals(2, versionDao.getAppVersions(repoId, packageName).getOrFail().size) + assertEquals(1, versionDao.getAppVersions(repoId2, packageName).getOrFail().size) + // disable second repo repoDao.setRepositoryEnabled(repoId2, false) @@ -155,8 +162,10 @@ internal class VersionTest : DbTest() { versionDao.insert(repoId, packageName, versionId3, packageVersion3, true) val versions1 = versionDao.getAppVersions(packageName).getOrFail() val versions2 = versionDao.getVersions(listOf(packageName)) + val versions3 = versionDao.getAppVersions(repoId, packageName).getOrFail() assertEquals(3, versions1.size) assertEquals(3, versions2.size) + assertEquals(3, versions3.size) // check that they are sorted as expected listOf( @@ -166,6 +175,7 @@ internal class VersionTest : DbTest() { ).sortedDescending().forEachIndexed { i, versionCode -> assertEquals(versionCode, versions1[i].version.manifest.versionCode) assertEquals(versionCode, versions2[i].versionCode) + assertEquals(versionCode, versions3[i].version.manifest.versionCode) } } diff --git a/libs/database/src/main/java/org/fdroid/database/AppDao.kt b/libs/database/src/main/java/org/fdroid/database/AppDao.kt index 5f5e17c37..6a17deeb5 100644 --- a/libs/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/AppDao.kt @@ -305,7 +305,9 @@ internal interface AppDaoInt : AppDao { @Transaction @Query("""SELECT ${AppMetadata.TABLE}.* FROM ${AppMetadata.TABLE} JOIN RepositoryPreferences AS pref USING (repoId) - WHERE packageName = :packageName AND pref.enabled = 1 + LEFT JOIN AppPrefs USING (packageName) + WHERE packageName = :packageName AND pref.enabled = 1 AND + COALESCE(preferredRepoId, repoId) = repoId ORDER BY pref.weight DESC LIMIT 1""") override fun getApp(packageName: String): LiveData @@ -341,7 +343,8 @@ internal interface AppDaoInt : AppDao { JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) LEFT JOIN ${LocalizedIcon.TABLE} AS icon USING (repoId, packageName) - WHERE pref.enabled = 1 + LEFT JOIN AppPrefs USING (packageName) + WHERE pref.enabled = 1 AND COALESCE(preferredRepoId, repoId) = repoId GROUP BY packageName HAVING MAX(pref.weight) ORDER BY localizedName IS NULL ASC, icon.packageName IS NULL ASC, localizedSummary IS NULL ASC, app.lastUpdated DESC @@ -355,7 +358,9 @@ internal interface AppDaoInt : AppDao { JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) LEFT JOIN ${LocalizedIcon.TABLE} AS icon USING (repoId, packageName) - WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' + LEFT JOIN AppPrefs USING (packageName) + WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' AND + COALESCE(preferredRepoId, repoId) = repoId GROUP BY packageName HAVING MAX(pref.weight) ORDER BY localizedName IS NULL ASC, icon.packageName IS NULL ASC, localizedSummary IS NULL ASC, app.lastUpdated DESC @@ -440,8 +445,10 @@ internal interface AppDaoInt : AppDao { FROM ${AppMetadata.TABLE} AS app JOIN ${AppMetadataFts.TABLE} USING (repoId, packageName) LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) + LEFT JOIN AppPrefs USING (packageName) JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) - WHERE pref.enabled = 1 AND ${AppMetadataFts.TABLE} MATCH :searchQuery + WHERE pref.enabled = 1 AND ${AppMetadataFts.TABLE} MATCH :searchQuery AND + COALESCE(preferredRepoId, repoId) = repoId GROUP BY packageName HAVING MAX(pref.weight)""") fun getAppListItems(searchQuery: String): LiveData> @@ -455,9 +462,11 @@ internal interface AppDaoInt : AppDao { FROM ${AppMetadata.TABLE} AS app JOIN ${AppMetadataFts.TABLE} USING (repoId, packageName) LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) + LEFT JOIN AppPrefs USING (packageName) JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' AND - ${AppMetadataFts.TABLE} MATCH :searchQuery + ${AppMetadataFts.TABLE} MATCH :searchQuery AND + COALESCE(preferredRepoId, repoId) = repoId GROUP BY packageName HAVING MAX(pref.weight)""") fun getAppListItems(category: String, searchQuery: String): LiveData> @@ -485,8 +494,9 @@ internal interface AppDaoInt : AppDao { version.antiFeatures, app.isCompatible, app.preferredSigner FROM ${AppMetadata.TABLE} AS app LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) + LEFT JOIN AppPrefs USING (packageName) JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) - WHERE pref.enabled = 1 + WHERE pref.enabled = 1 AND COALESCE(preferredRepoId, repoId) = repoId GROUP BY packageName HAVING MAX(pref.weight) ORDER BY localizedName COLLATE NOCASE ASC""") fun getAppListItemsByName(): LiveData> @@ -498,7 +508,8 @@ internal interface AppDaoInt : AppDao { FROM ${AppMetadata.TABLE} AS app JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) - WHERE pref.enabled = 1 + LEFT JOIN AppPrefs USING (packageName) + WHERE pref.enabled = 1 AND COALESCE(preferredRepoId, repoId) = repoId GROUP BY packageName HAVING MAX(pref.weight) ORDER BY app.lastUpdated DESC""") fun getAppListItemsByLastUpdated(): LiveData> @@ -510,7 +521,9 @@ internal interface AppDaoInt : AppDao { FROM ${AppMetadata.TABLE} AS app JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) - WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' + LEFT JOIN AppPrefs USING (packageName) + WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' AND + COALESCE(preferredRepoId, repoId) = repoId GROUP BY packageName HAVING MAX(pref.weight) ORDER BY app.lastUpdated DESC""") fun getAppListItemsByLastUpdated(category: String): LiveData> @@ -522,7 +535,9 @@ internal interface AppDaoInt : AppDao { FROM ${AppMetadata.TABLE} AS app JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) - WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' + LEFT JOIN AppPrefs USING (packageName) + WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' AND + COALESCE(preferredRepoId, repoId) = repoId GROUP BY packageName HAVING MAX(pref.weight) ORDER BY localizedName COLLATE NOCASE ASC""") fun getAppListItemsByName(category: String): LiveData> @@ -556,7 +571,9 @@ internal interface AppDaoInt : AppDao { app.isCompatible, app.preferredSigner FROM ${AppMetadata.TABLE} AS app JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) - WHERE pref.enabled = 1 AND packageName IN (:packageNames) + LEFT JOIN AppPrefs USING (packageName) + WHERE pref.enabled = 1 AND packageName IN (:packageNames) AND + COALESCE(preferredRepoId, repoId) = repoId GROUP BY packageName HAVING MAX(pref.weight) ORDER BY localizedName COLLATE NOCASE ASC""") fun getAppListItems(packageNames: List): LiveData> diff --git a/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt b/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt index 3b08bdc09..8b500b6e1 100644 --- a/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt +++ b/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt @@ -13,6 +13,7 @@ public data class AppPrefs( @PrimaryKey val packageName: String, override val ignoreVersionCodeUpdate: Long = 0, + val preferredRepoId: Long? = null, // This is named like this, because it hit a Room bug when joining with Version table // which had exactly the same field. internal val appPrefReleaseChannels: List? = null, diff --git a/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index be228fb03..876fc43d6 100644 --- a/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -3,6 +3,7 @@ package org.fdroid.database import android.content.res.Resources import androidx.core.os.ConfigurationCompat.getLocales import androidx.core.os.LocaleListCompat +import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters @@ -14,7 +15,7 @@ import java.util.concurrent.Callable // When bumping this version, please make sure to add one (or more) migration(s) below! // Consider also providing tests for that migration. // Don't forget to commit the new schema to the git repo as well. - version = 1, + version = 2, entities = [ // repo CoreRepository::class, @@ -41,6 +42,7 @@ import java.util.concurrent.Callable exportSchema = true, autoMigrations = [ // add future migrations here (if they are easy enough to be done automatically) + AutoMigration(1, 2), ], ) @TypeConverters(Converters::class) diff --git a/libs/database/src/main/java/org/fdroid/database/VersionDao.kt b/libs/database/src/main/java/org/fdroid/database/VersionDao.kt index 1bf357e96..f5309a3e3 100644 --- a/libs/database/src/main/java/org/fdroid/database/VersionDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/VersionDao.kt @@ -35,6 +35,12 @@ public interface VersionDao { * Returns a list of versions for the given [packageName] sorting by highest version code first. */ public fun getAppVersions(packageName: String): LiveData> + + /** + * Returns a list of versions from the repo identified by the given [repoId] + * for the given [packageName] sorting by highest version code first. + */ + public fun getAppVersions(repoId: Long, packageName: String): LiveData> } /** @@ -161,13 +167,12 @@ internal interface VersionDaoInt : VersionDao { ORDER BY manifest_versionCode DESC, pref.weight DESC""") override fun getAppVersions(packageName: String): LiveData> - /** - * Only use for testing, not sorted, does take disabled repos into account. - */ @Transaction + @RewriteQueriesToDropUnusedColumns @Query("""SELECT * FROM ${Version.TABLE} - WHERE repoId = :repoId AND packageName = :packageName""") - fun getAppVersions(repoId: Long, packageName: String): List + WHERE repoId = :repoId AND packageName = :packageName + ORDER BY manifest_versionCode DESC""") + override fun getAppVersions(repoId: Long, packageName: String): LiveData> @Query("""SELECT * FROM ${Version.TABLE} WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""") From 5c27f7033e60d67b21db3fd2f771d7bd6b39abec Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 2 Nov 2023 12:10:56 -0300 Subject: [PATCH 02/33] [db] Move InstantTaskExecutorRule into base DbTest class so all tests can use LiveData helper methods such as getOrFail() without worrying about that rule --- libs/database/src/dbTest/java/org/fdroid/database/AppTest.kt | 5 ----- libs/database/src/dbTest/java/org/fdroid/database/DbTest.kt | 5 +++++ .../src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt | 5 ----- .../src/dbTest/java/org/fdroid/database/VersionTest.kt | 5 ----- .../dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt | 4 ---- 5 files changed, 5 insertions(+), 19 deletions(-) diff --git a/libs/database/src/dbTest/java/org/fdroid/database/AppTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppTest.kt index bbaf51cda..056e8fc45 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/AppTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/AppTest.kt @@ -1,17 +1,12 @@ package org.fdroid.database -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import org.fdroid.test.TestAppUtils.getRandomMetadataV2 import org.fdroid.test.TestRepoUtils.getRandomFileV2 import org.fdroid.test.TestUtils.getRandomString import org.fdroid.test.TestUtils.sort -import org.junit.Rule internal abstract class AppTest : DbTest() { - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - protected val packageName = getRandomString() protected val packageName1 = getRandomString() protected val packageName2 = getRandomString() diff --git a/libs/database/src/dbTest/java/org/fdroid/database/DbTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/DbTest.kt index ddcec7551..4f96668e3 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/DbTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/DbTest.kt @@ -3,6 +3,7 @@ package org.fdroid.database import android.content.Context import android.content.res.AssetManager import android.os.Build +import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.core.os.LocaleListCompat import androidx.room.Room import androidx.test.core.app.ApplicationProvider.getApplicationContext @@ -22,6 +23,7 @@ import org.fdroid.test.VerifierConstants.CERTIFICATE import org.junit.After import org.junit.Assume.assumeTrue import org.junit.Before +import org.junit.Rule import java.io.IOException import java.util.Locale import kotlin.test.assertEquals @@ -29,6 +31,9 @@ import kotlin.test.fail internal abstract class DbTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + internal lateinit var repoDao: RepositoryDaoInt internal lateinit var appDao: AppDaoInt internal lateinit var appPrefsDao: AppPrefsDaoInt diff --git a/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt index 2b284beb6..cde57bddf 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt @@ -1,6 +1,5 @@ package org.fdroid.database -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.ext.junit.runners.AndroidJUnit4 import org.fdroid.database.TestUtils.assertRepoEquals import org.fdroid.database.TestUtils.getOrFail @@ -9,7 +8,6 @@ import org.fdroid.test.TestRepoUtils.getRandomRepo import org.fdroid.test.TestUtils.getRandomString import org.fdroid.test.TestUtils.orNull import org.fdroid.test.TestVersionUtils.getRandomPackageVersionV2 -import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import kotlin.random.Random @@ -22,9 +20,6 @@ import kotlin.test.fail @RunWith(AndroidJUnit4::class) internal class RepositoryDaoTest : DbTest() { - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - @Test fun testInsertInitialRepository() { val repo = InitialRepository( diff --git a/libs/database/src/dbTest/java/org/fdroid/database/VersionTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/VersionTest.kt index 244dcf79d..bbf2b3174 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/VersionTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/VersionTest.kt @@ -1,6 +1,5 @@ package org.fdroid.database -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.ext.junit.runners.AndroidJUnit4 import org.fdroid.database.TestUtils.getOrFail import org.fdroid.index.v2.PackageVersionV2 @@ -8,7 +7,6 @@ import org.fdroid.test.TestAppUtils.getRandomMetadataV2 import org.fdroid.test.TestRepoUtils.getRandomRepo import org.fdroid.test.TestUtils.getRandomString import org.fdroid.test.TestVersionUtils.getRandomPackageVersionV2 -import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import kotlin.random.Random @@ -18,9 +16,6 @@ import kotlin.test.fail @RunWith(AndroidJUnit4::class) internal class VersionTest : DbTest() { - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - private val packageName = getRandomString() private val packageVersion1 = getRandomPackageVersionV2() private val packageVersion2 = getRandomPackageVersionV2() diff --git a/libs/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt b/libs/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt index cd8079bfb..167c416c0 100644 --- a/libs/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt @@ -2,7 +2,6 @@ package org.fdroid.index.v1 import android.Manifest import android.net.Uri -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.ext.junit.runners.AndroidJUnit4 import io.mockk.Runs import io.mockk.every @@ -39,9 +38,6 @@ internal class IndexV1UpdaterTest : DbTest() { @get:Rule var tmpFolder: TemporaryFolder = TemporaryFolder() - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - private val tempFileProvider: TempFileProvider = mockk() private val downloaderFactory: DownloaderFactory = mockk() private val downloader: Downloader = mockk() From 12af2fa32bab19c2aba3fa5228864785d2174ab8 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 19 Oct 2023 15:44:50 -0300 Subject: [PATCH 03/33] [db] reset the preferred repo when it gets disabled or deleted --- .../java/org/fdroid/database/AppDaoTest.kt | 6 ++ .../org/fdroid/database/AppPrefsDaoTest.kt | 57 +++++++++++++++++++ .../java/org/fdroid/database/RepositoryDao.kt | 16 +++++- 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 libs/database/src/dbTest/java/org/fdroid/database/AppPrefsDaoTest.kt diff --git a/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt index 5c3d0aa24..77e09654b 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt @@ -2,6 +2,7 @@ package org.fdroid.database import androidx.core.os.LocaleListCompat import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fdroid.database.TestUtils.getOrAwaitValue import org.fdroid.database.TestUtils.getOrFail import org.fdroid.database.TestUtils.toMetadataV2 import org.fdroid.test.TestRepoUtils.getRandomRepo @@ -10,6 +11,7 @@ import org.fdroid.test.TestVersionUtils.getRandomPackageVersionV2 import org.junit.Test import org.junit.runner.RunWith import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.test.fail @@ -82,6 +84,10 @@ internal class AppDaoTest : AppTest() { // prefer repo1 for this app appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId1)) assertEquals(app1, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort()) + + // preferring non-existent repo for this app makes query return nothing (avoid this!) + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = 1337L)) + assertNull(appDao.getApp(packageName).getOrAwaitValue()) } @Test diff --git a/libs/database/src/dbTest/java/org/fdroid/database/AppPrefsDaoTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppPrefsDaoTest.kt new file mode 100644 index 000000000..2abae586b --- /dev/null +++ b/libs/database/src/dbTest/java/org/fdroid/database/AppPrefsDaoTest.kt @@ -0,0 +1,57 @@ +package org.fdroid.database + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fdroid.database.TestUtils.getOrFail +import org.fdroid.database.TestUtils.toMetadataV2 +import org.fdroid.test.TestRepoUtils.getRandomRepo +import org.fdroid.test.TestUtils.sort +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +internal class AppPrefsDaoTest : AppTest() { + + @Test + fun testDisablingPreferredRepo() { + // insert same app into three repos (repoId3 has highest weight) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId1, packageName, app1, locales) + appDao.insert(repoId2, packageName, app2, locales) + appDao.insert(repoId2, packageName, app3, locales) + + // app from preferred repo gets returned + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId1)) + assertEquals(app1, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort()) + + // preferred repo gets disabled + repoDao.setRepositoryEnabled(repoId1, false) + + // now app from repo with highest weight is returned + assertEquals(app3, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort()) + } + + @Test + fun testRemovingPreferredRepo() { + // insert same app into three repos (repoId3 has highest weight) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId1, packageName, app1, locales) + appDao.insert(repoId2, packageName, app2, locales) + appDao.insert(repoId2, packageName, app3, locales) + + // app from preferred repo gets returned + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId1)) + assertEquals(app1, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort()) + + // preferred repo gets removed + repoDao.deleteRepository(repoId1) + + // now app from repo with highest weight is returned + assertEquals(app3, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort()) + } + +} diff --git a/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt index 022b50510..281d68122 100644 --- a/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -329,8 +329,19 @@ internal interface RepositoryDaoInt : RepositoryDao { ) } + @Transaction + override fun setRepositoryEnabled(repoId: Long, enabled: Boolean) { + // When disabling a repository, we need to remove it as preferred repo for all apps, + // otherwise our queries that ignore disabled repos will not return anything anymore. + if (!enabled) resetPreferredRepoInAppPrefs(repoId) + setRepositoryEnabledInternal(repoId, enabled) + } + @Query("UPDATE ${RepositoryPreferences.TABLE} SET enabled = :enabled WHERE repoId = :repoId") - override fun setRepositoryEnabled(repoId: Long, enabled: Boolean) + fun setRepositoryEnabledInternal(repoId: Long, enabled: Boolean) + + @Query("UPDATE ${AppPrefs.TABLE} SET preferredRepoId = NULL WHERE preferredRepoId = :repoId") + fun resetPreferredRepoInAppPrefs(repoId: Long) @Query("""UPDATE ${RepositoryPreferences.TABLE} SET userMirrors = :mirrors WHERE repoId = :repoId""") @@ -350,6 +361,9 @@ internal interface RepositoryDaoInt : RepositoryDao { // we don't use cascading delete for preferences, // so we can replace index data on full updates deleteRepositoryPreferences(repoId) + // When deleting a repository, we need to remove it as preferred repo for all apps, + // otherwise our queries will not return anything anymore. + resetPreferredRepoInAppPrefs(repoId) } @Query("DELETE FROM ${CoreRepository.TABLE} WHERE repoId = :repoId") From 393c74ab35977cb5695b044406602ef3c62ba5db Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 20 Oct 2023 09:15:51 -0300 Subject: [PATCH 04/33] [app] adapt to new AppPrefs constructor --- .../java/org/fdroid/fdroid/data/ContentProviderMigrator.java | 2 +- .../test/java/org/fdroid/fdroid/data/SuggestedVersionTest.java | 2 +- .../java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/data/ContentProviderMigrator.java b/app/src/main/java/org/fdroid/fdroid/data/ContentProviderMigrator.java index dada626b5..1403f01fc 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/ContentProviderMigrator.java +++ b/app/src/main/java/org/fdroid/fdroid/data/ContentProviderMigrator.java @@ -123,7 +123,7 @@ final class ContentProviderMigrator { // ignored version code is max code to ignore all updates, or a specific one to ignore long v = ignoreAllUpdates ? Long.MAX_VALUE : ignoreVersionCode; // this is a new DB, so we can just start to insert new AppPrefs - appPrefsDao.update(new AppPrefs(packageName, v, null)); + appPrefsDao.update(new AppPrefs(packageName, v, null, null)); } } } diff --git a/app/src/test/java/org/fdroid/fdroid/data/SuggestedVersionTest.java b/app/src/test/java/org/fdroid/fdroid/data/SuggestedVersionTest.java index aab71e7d0..9a9c6c090 100644 --- a/app/src/test/java/org/fdroid/fdroid/data/SuggestedVersionTest.java +++ b/app/src/test/java/org/fdroid/fdroid/data/SuggestedVersionTest.java @@ -135,7 +135,7 @@ public class SuggestedVersionTest { assertEquals("Installed signature on Apk", app.installedSigner, suggestedApk.signer); } assertTrue(app.canAndWantToUpdate(suggestedApk)); - AppPrefs appPrefs = new AppPrefs(app.packageName, 0, Collections.singletonList(releaseChannel)); + AppPrefs appPrefs = new AppPrefs(app.packageName, 0, null, Collections.singletonList(releaseChannel)); assertEquals(hasUpdates, app.hasUpdates(apks, appPrefs)); } } diff --git a/app/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java b/app/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java index de91a0c36..b99a725a3 100644 --- a/app/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java +++ b/app/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java @@ -32,7 +32,7 @@ import java.util.List; @RunWith(RobolectricTestRunner.class) public class AppDetailsAdapterTest { - private final AppPrefs appPrefs = new AppPrefs("com.example.app", 0, null); + private final AppPrefs appPrefs = new AppPrefs("com.example.app", 0, null, null); private Context context; @Before From ead8bd126275609ada94cfb479970508e5cd6bf9 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 24 Oct 2023 15:41:58 -0300 Subject: [PATCH 05/33] [db] Only consider preferred versions for updates and suggested version --- .../2.json | 8 +- .../org/fdroid/database/AppPrefsDaoTest.kt | 61 ++++++- .../fdroid/database/DbUpdateCheckerTest.kt | 151 +++++++++++++++++- .../src/main/java/org/fdroid/database/App.kt | 1 + .../main/java/org/fdroid/database/AppPrefs.kt | 11 ++ .../java/org/fdroid/database/AppPrefsDao.kt | 18 +++ .../org/fdroid/database/DbUpdateChecker.kt | 38 ++++- .../org/fdroid/database/FDroidDatabase.kt | 1 + .../org/fdroid/test/TestVersionUtils.kt | 11 +- 9 files changed, 284 insertions(+), 16 deletions(-) diff --git a/libs/database/schemas/org.fdroid.database.FDroidDatabaseInt/2.json b/libs/database/schemas/org.fdroid.database.FDroidDatabaseInt/2.json index ecb4afb99..b902276c7 100644 --- a/libs/database/schemas/org.fdroid.database.FDroidDatabaseInt/2.json +++ b/libs/database/schemas/org.fdroid.database.FDroidDatabaseInt/2.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 2, - "identityHash": "6f1a1580d37e51593441ffa87a858914", + "identityHash": "0f46ee261c488b0d38dae6aa541b57cc", "entities": [ { "tableName": "CoreRepository", @@ -1084,11 +1084,15 @@ { "viewName": "HighestVersion", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT repoId, packageName, antiFeatures FROM Version\n GROUP BY repoId, packageName HAVING MAX(manifest_versionCode)" + }, + { + "viewName": "PreferredRepo", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT packageName, repoId AS preferredRepoId FROM AppMetadata\n JOIN RepositoryPreferences AS pref USING (repoId)\n LEFT JOIN AppPrefs USING (packageName)\n WHERE repoId = COALESCE(preferredRepoId, repoId)\n GROUP BY packageName HAVING MAX(pref.weight)" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6f1a1580d37e51593441ffa87a858914')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0f46ee261c488b0d38dae6aa541b57cc')" ] } } \ No newline at end of file diff --git a/libs/database/src/dbTest/java/org/fdroid/database/AppPrefsDaoTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppPrefsDaoTest.kt index 2abae586b..b9d51c206 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/AppPrefsDaoTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/AppPrefsDaoTest.kt @@ -17,7 +17,6 @@ internal class AppPrefsDaoTest : AppTest() { // insert same app into three repos (repoId3 has highest weight) val repoId1 = repoDao.insertOrReplace(getRandomRepo()) val repoId2 = repoDao.insertOrReplace(getRandomRepo()) - val repoId3 = repoDao.insertOrReplace(getRandomRepo()) appDao.insert(repoId1, packageName, app1, locales) appDao.insert(repoId2, packageName, app2, locales) appDao.insert(repoId2, packageName, app3, locales) @@ -38,7 +37,6 @@ internal class AppPrefsDaoTest : AppTest() { // insert same app into three repos (repoId3 has highest weight) val repoId1 = repoDao.insertOrReplace(getRandomRepo()) val repoId2 = repoDao.insertOrReplace(getRandomRepo()) - val repoId3 = repoDao.insertOrReplace(getRandomRepo()) appDao.insert(repoId1, packageName, app1, locales) appDao.insert(repoId2, packageName, app2, locales) appDao.insert(repoId2, packageName, app3, locales) @@ -54,4 +52,63 @@ internal class AppPrefsDaoTest : AppTest() { assertEquals(app3, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort()) } + @Test + fun testGetPreferredRepos() { + // insert three apps, the third is in repo2 and repo3 + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId1, packageName1, app1, locales) + appDao.insert(repoId2, packageName2, app2, locales) + appDao.insert(repoId2, packageName3, app3, locales) + appDao.insert(repoId3, packageName3, app3, locales) + + // app1 and app2 are only in one repo, so that one is preferred + appPrefsDao.getPreferredRepos(listOf(packageName1, packageName2)).also { preferredRepos -> + assertEquals(2, preferredRepos.size) + assertEquals(repoId1, preferredRepos[packageName1]) + assertEquals(repoId2, preferredRepos[packageName2]) + } + + // preference only based on global repo priority/weight (3>2>1) + appPrefsDao.getPreferredRepos(listOf(packageName3, packageName2)).also { preferredRepos -> + assertEquals(2, preferredRepos.size) + assertEquals(repoId2, preferredRepos[packageName2]) + assertEquals(repoId3, preferredRepos[packageName3]) + } + + // now app3 prefers repo2 explicitly + appPrefsDao.update(AppPrefs(packageName3, preferredRepoId = repoId2)) + appPrefsDao.getPreferredRepos(listOf(packageName3)).also { preferredRepos -> + assertEquals(1, preferredRepos.size) + assertEquals(repoId2, preferredRepos[packageName3]) + } + + // app3 moves back to preferring repo3 and query for non-existent package name as well + appPrefsDao.update(AppPrefs(packageName3, preferredRepoId = repoId3)) + appPrefsDao.getPreferredRepos(listOf(packageName, packageName3)).also { preferredRepos -> + assertEquals(1, preferredRepos.size) + assertEquals(repoId3, preferredRepos[packageName3]) + } + } + + @Test + fun getGetPreferredReposHandlesMaxVariableNumber() { + // sqlite has a maximum number of 999 variables that can be used in a query + val packagesOk = MutableList(998) { "" } + listOf(packageName) + val packagesNotOk1 = MutableList(1000) { "" } + listOf(packageName) + val packagesNotOk2 = MutableList(5000) { "" } + listOf(packageName) + + // insert same app in three repos + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName, app1, locales) + appDao.insert(repoId, packageName, app2, locales) + appDao.insert(repoId, packageName, app3, locales) + + // preferred repos are returned as expected for all lists, no matter their size + assertEquals(1, appPrefsDao.getPreferredRepos(packagesOk).size) + assertEquals(1, appPrefsDao.getPreferredRepos(packagesNotOk1).size) + assertEquals(1, appPrefsDao.getPreferredRepos(packagesNotOk2).size) + } + } diff --git a/libs/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt index 864bb493b..2f83e87ae 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt @@ -2,10 +2,12 @@ package org.fdroid.database import android.content.pm.PackageInfo import android.content.pm.PackageManager +import android.content.pm.PackageManager.NameNotFoundException import androidx.test.ext.junit.runners.AndroidJUnit4 import io.mockk.every import io.mockk.mockk import org.fdroid.index.RELEASE_CHANNEL_BETA +import org.fdroid.index.v2.PackageVersionV2 import org.fdroid.index.v2.SignerV2 import org.fdroid.test.TestDataMidV2 import org.fdroid.test.TestDataMinV2 @@ -15,17 +17,21 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue +@Suppress("DEPRECATION") @RunWith(AndroidJUnit4::class) internal class DbUpdateCheckerTest : AppTest() { private lateinit var updateChecker: DbUpdateChecker private val packageManager: PackageManager = mockk() + private val compatChecker: (PackageVersionV2) -> Boolean = { true } private val packageInfo = PackageInfo().apply { packageName = TestDataMinV2.packageName - @Suppress("DEPRECATION") versionCode = 0 } @@ -120,12 +126,90 @@ internal class DbUpdateCheckerTest : AppTest() { // if signer of second version is preferred second version is suggested as update assertEquals( version2, - updateChecker.getSuggestedVersion(packageName, + updateChecker.getSuggestedVersion( + packageName = packageName, preferredSigner = signer2.sha256[0] )?.version, ) } + @Test + fun testSuggestedVersionOnlyFromPreferredRepo() { + // insert the same app into two repos + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId1, packageName, app1, locales) + appDao.insert(repoId2, packageName, app2, locales) + + // every app has a compatible version + val packageVersion1 = mapOf( + "1" to getRandomPackageVersionV2(2, null).copy(releaseChannels = emptyList()) + ) + val packageVersion2 = mapOf( + "2" to getRandomPackageVersionV2(1, null).copy(releaseChannels = emptyList()) + ) + versionDao.insert(repoId1, packageName, packageVersion1, compatChecker) + versionDao.insert(repoId2, packageName, packageVersion2, compatChecker) + + // nothing is installed + every { + packageManager.getPackageInfo(packageName, any()) + } throws NameNotFoundException() + + // without preferring repos, version with highest version code gets returned + updateChecker.getSuggestedVersion(packageName).also { appVersion -> + assertNotNull(appVersion) + assertEquals(repoId1, appVersion.repoId) + assertEquals(2, appVersion.manifest.versionCode) + } + + // now we want versions only from preferred repo and get the one with highest weight + updateChecker.getSuggestedVersion(packageName, onlyFromPreferredRepo = true) + .also { appVersion -> + assertNotNull(appVersion) + assertEquals(repoId2, appVersion.repoId) + assertEquals(1, appVersion.manifest.versionCode) + } + + // now we allow all repos, but explicitly prefer repo 1, getting same result as above + appPrefsDao.update(AppPrefs(packageInfo.packageName, preferredRepoId = repoId1)) + updateChecker.getSuggestedVersion(packageName).also { appVersion -> + assertNotNull(appVersion) + assertEquals(repoId1, appVersion.repoId) + assertEquals(2, appVersion.manifest.versionCode) + } + + // now we prefer repo 2 and only want versions from preferred repo + appPrefsDao.update(AppPrefs(packageInfo.packageName, preferredRepoId = repoId2)) + updateChecker.getSuggestedVersion(packageName, onlyFromPreferredRepo = true) + .also { appVersion -> + assertNotNull(appVersion) + assertEquals(repoId2, appVersion.repoId) + assertEquals(1, appVersion.manifest.versionCode) + } + + // now we have version 1 already installed + every { + packageManager.getPackageInfo(packageName, any()) + } returns PackageInfo().apply { + packageName = this@DbUpdateCheckerTest.packageName + versionCode = 1 + } + + // preferred repos don't have suggested versions + updateChecker.getSuggestedVersion(packageName, onlyFromPreferredRepo = true) + .also { appVersion -> + assertNull(appVersion) + } + + // but other repos still have + updateChecker.getSuggestedVersion(packageName).also { appVersion -> + assertNotNull(appVersion) + assertEquals(repoId1, appVersion.repoId) + assertEquals(2, appVersion.manifest.versionCode) + } + } + @Test fun testGetUpdatableApps() { streamIndexV2IntoDb("index-min-v2.json") @@ -138,4 +222,67 @@ internal class DbUpdateCheckerTest : AppTest() { assertEquals(TestDataMinV2.version.file.sha256, appVersions[0].update.version.versionId) } + @Test + fun testGetUpdatableAppsOnlyFromPreferredRepo() { + // insert the same app into three repos + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId1, packageInfo.packageName, app1, locales) + appDao.insert(repoId2, packageInfo.packageName, app2, locales) + appDao.insert(repoId3, packageInfo.packageName, app3, locales) + + // every app has a compatible update (versionCode greater than 0) + val packageVersion1 = mapOf( + "1" to getRandomPackageVersionV2(13, null).copy(releaseChannels = emptyList()) + ) + val packageVersion2 = mapOf( + "2" to getRandomPackageVersionV2(12, null).copy(releaseChannels = emptyList()) + ) + val packageVersion3 = mapOf( + "3" to getRandomPackageVersionV2(10, null).copy(releaseChannels = emptyList()) + ) + versionDao.insert(repoId1, packageInfo.packageName, packageVersion1, compatChecker) + versionDao.insert(repoId2, packageInfo.packageName, packageVersion2, compatChecker) + versionDao.insert(repoId3, packageInfo.packageName, packageVersion3, compatChecker) + + // app is installed with version code 0 + assertEquals(0, packageInfo.versionCode) + every { packageManager.getInstalledPackages(any()) } returns listOf(packageInfo) + + // without preferring repos, version with highest version code gets returned + updateChecker.getUpdatableApps().also { appVersions -> + assertEquals(1, appVersions.size) + assertEquals(repoId1, appVersions[0].repoId) + assertEquals(13, appVersions[0].update.manifest.versionCode) + assertFalse(appVersions[0].isFromPreferredRepo) // preferred repo is 3 per weight + } + + // now we want versions only from preferred repo and get the one with highest weight + updateChecker.getUpdatableApps(onlyFromPreferredRepo = true).also { appVersions -> + assertEquals(1, appVersions.size) + assertEquals(repoId3, appVersions[0].repoId) + assertEquals(10, appVersions[0].update.manifest.versionCode) + assertTrue(appVersions[0].isFromPreferredRepo) // preferred repo is 3 due to weight + } + + // now we allow all repos, but explicitly prefer repo 1, isFromPreferredRepo becomes true + appPrefsDao.update(AppPrefs(packageInfo.packageName, preferredRepoId = repoId1)) + updateChecker.getUpdatableApps().also { appVersions -> + assertEquals(1, appVersions.size) + assertEquals(repoId1, appVersions[0].repoId) + assertEquals(13, appVersions[0].update.manifest.versionCode) + assertTrue(appVersions[0].isFromPreferredRepo) // preferred repo is 1 now + } + + // now we prefer repo 2 and only want versions from preferred repo + appPrefsDao.update(AppPrefs(packageInfo.packageName, preferredRepoId = repoId2)) + updateChecker.getUpdatableApps(onlyFromPreferredRepo = true).also { appVersions -> + assertEquals(1, appVersions.size) + assertEquals(repoId2, appVersions[0].repoId) + assertEquals(12, appVersions[0].update.manifest.versionCode) + assertTrue(appVersions[0].isFromPreferredRepo) // preferred repo is 2 now + } + } + } diff --git a/libs/database/src/main/java/org/fdroid/database/App.kt b/libs/database/src/main/java/org/fdroid/database/App.kt index d75edbc2a..58ec6f82a 100644 --- a/libs/database/src/main/java/org/fdroid/database/App.kt +++ b/libs/database/src/main/java/org/fdroid/database/App.kt @@ -334,6 +334,7 @@ public data class UpdatableApp internal constructor( public override val packageName: String, public val installedVersionCode: Long, public val update: AppVersion, + public val isFromPreferredRepo: Boolean, /** * If true, this is not necessarily an update (contrary to the class name), * but an app with the `KnownVuln` anti-feature. diff --git a/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt b/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt index 8b500b6e1..1cf34bb3f 100644 --- a/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt +++ b/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt @@ -1,5 +1,6 @@ package org.fdroid.database +import androidx.room.DatabaseView import androidx.room.Entity import androidx.room.PrimaryKey import org.fdroid.PackagePreference @@ -54,3 +55,13 @@ public data class AppPrefs( }, ) } + +@DatabaseView("""SELECT packageName, repoId AS preferredRepoId FROM ${AppMetadata.TABLE} + JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) + LEFT JOIN ${AppPrefs.TABLE} USING (packageName) + WHERE repoId = COALESCE(preferredRepoId, repoId) + GROUP BY packageName HAVING MAX(pref.weight)""") +internal class PreferredRepo( + val packageName: String, + val preferredRepoId: Long, +) diff --git a/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt b/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt index d14c09913..d6eb2e357 100644 --- a/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import androidx.room.Dao import androidx.room.Insert +import androidx.room.MapInfo import androidx.room.OnConflictStrategy.Companion.REPLACE import androidx.room.Query @@ -28,6 +29,23 @@ internal interface AppPrefsDaoInt : AppPrefsDao { @Query("SELECT * FROM ${AppPrefs.TABLE} WHERE packageName = :packageName") fun getAppPrefsOrNull(packageName: String): AppPrefs? + fun getPreferredRepos(packageNames: List): Map { + return if (packageNames.size <= 999) getPreferredReposInternal(packageNames) + else HashMap(packageNames.size).also { map -> + packageNames.chunked(999).forEach { map.putAll(getPreferredReposInternal(it)) } + } + } + + /** + * Use [getPreferredRepos] instead as this handles more than 1000 package names. + */ + @MapInfo(keyColumn = "packageName", valueColumn = "preferredRepoId") + @Query( + """SELECT packageName, preferredRepoId FROM PreferredRepo + WHERE packageName IN (:packageNames)""" + ) + fun getPreferredReposInternal(packageNames: List): Map + @Insert(onConflict = REPLACE) override fun update(appPrefs: AppPrefs) } diff --git a/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt b/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt index 470979a78..f77c1b4e7 100644 --- a/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt +++ b/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt @@ -4,7 +4,7 @@ import android.annotation.SuppressLint import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.pm.PackageManager.GET_SIGNATURES -import androidx.core.content.pm.PackageInfoCompat +import androidx.core.content.pm.PackageInfoCompat.getLongVersionCode import org.fdroid.CompatibilityChecker import org.fdroid.CompatibilityCheckerImpl import org.fdroid.PackagePreference @@ -29,6 +29,7 @@ public class DbUpdateChecker @JvmOverloads constructor( @JvmOverloads public fun getUpdatableApps( releaseChannels: List? = null, + onlyFromPreferredRepo: Boolean = false, includeKnownVulnerabilities: Boolean = false, ): List { val updatableApps = ArrayList() @@ -36,8 +37,14 @@ public class DbUpdateChecker @JvmOverloads constructor( @Suppress("DEPRECATION") // we'll use this as long as it works, new one was broken val installedPackages = packageManager.getInstalledPackages(GET_SIGNATURES) val packageNames = installedPackages.map { it.packageName } + val preferredRepos = appPrefsDao.getPreferredRepos(packageNames) + val versionsByPackage = HashMap>(packageNames.size) versionDao.getVersions(packageNames).forEach { version -> + val preferredRepoId = preferredRepos[version.packageName] + ?: error { "No preferred repo for ${version.packageName}" } + // disregard version, if we only want from preferred repo and this version is not + if (onlyFromPreferredRepo && preferredRepoId != version.repoId) return@forEach val list = versionsByPackage.getOrPut(version.packageName) { ArrayList() } list.add(version) } @@ -53,8 +60,13 @@ public class DbUpdateChecker @JvmOverloads constructor( includeKnownVulnerabilities = includeKnownVulnerabilities, ) if (version != null) { - val versionCode = PackageInfoCompat.getLongVersionCode(packageInfo) - val app = getUpdatableApp(version, versionCode) + val preferredRepoId = preferredRepos[packageName] + ?: error { "No preferred repo for $packageName" } + val app = getUpdatableApp( + version = version, + installedVersionCode = getLongVersionCode(packageInfo), + isFromPreferredRepo = preferredRepoId == version.repoId, + ) if (app != null) updatableApps.add(app) } } @@ -72,8 +84,19 @@ public class DbUpdateChecker @JvmOverloads constructor( packageName: String, preferredSigner: String? = null, releaseChannels: List? = null, + onlyFromPreferredRepo: Boolean = false, ): AppVersion? { - val versions = versionDao.getVersions(listOf(packageName)) + val preferredRepoId = if (onlyFromPreferredRepo) { + appPrefsDao.getPreferredRepos(listOf(packageName))[packageName] + ?: error { "No preferred repo for $packageName" } + } else 0L + val versions = if (onlyFromPreferredRepo) { + versionDao.getVersions(listOf(packageName)).filter { version -> + version.repoId == preferredRepoId + } + } else { + versionDao.getVersions(listOf(packageName)) + } if (versions.isEmpty()) return null val packageInfo = try { @Suppress("DEPRECATION") @@ -125,7 +148,11 @@ public class DbUpdateChecker @JvmOverloads constructor( } } - private fun getUpdatableApp(version: Version, installedVersionCode: Long): UpdatableApp? { + private fun getUpdatableApp( + version: Version, + installedVersionCode: Long, + isFromPreferredRepo: Boolean, + ): UpdatableApp? { val versionedStrings = versionDao.getVersionedStrings( repoId = version.repoId, packageName = version.packageName, @@ -138,6 +165,7 @@ public class DbUpdateChecker @JvmOverloads constructor( packageName = version.packageName, installedVersionCode = installedVersionCode, update = version.toAppVersion(versionedStrings), + isFromPreferredRepo = isFromPreferredRepo, hasKnownVulnerability = version.hasKnownVulnerability, name = appOverviewItem.name, summary = appOverviewItem.summary, diff --git a/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index 876fc43d6..d44cf28a1 100644 --- a/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -38,6 +38,7 @@ import java.util.concurrent.Callable views = [ LocalizedIcon::class, HighestVersion::class, + PreferredRepo::class, ], exportSchema = true, autoMigrations = [ diff --git a/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestVersionUtils.kt b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestVersionUtils.kt index 2416c72f2..2803b58f6 100644 --- a/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestVersionUtils.kt +++ b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestVersionUtils.kt @@ -19,19 +19,22 @@ object TestVersionUtils { fun getRandomPackageVersionV2( versionCode: Long = Random.nextLong(1, Long.MAX_VALUE), + signer: SignerV2? = SignerV2(getRandomList(Random.nextInt(1, 3)) { + getRandomString(64) + }).orNull(), ) = PackageVersionV2( added = Random.nextLong(), file = getRandomFileV2(false).let { FileV1(it.name, it.sha256!!, it.size) }, src = getRandomFileV2().orNull(), - manifest = getRandomManifestV2(versionCode), + manifest = getRandomManifestV2(versionCode, signer), releaseChannels = getRandomList { getRandomString() }, antiFeatures = getRandomMap { getRandomString() to getRandomLocalizedTextV2() }, whatsNew = getRandomLocalizedTextV2(), ) - fun getRandomManifestV2(versionCode: Long) = ManifestV2( + private fun getRandomManifestV2(versionCode: Long, signer: SignerV2?) = ManifestV2( versionName = getRandomString(), versionCode = versionCode, usesSdk = UsesSdkV2( @@ -39,9 +42,7 @@ object TestVersionUtils { targetSdkVersion = Random.nextInt(), ), maxSdkVersion = Random.nextInt().orNull(), - signer = SignerV2(getRandomList(Random.nextInt(1, 3)) { - getRandomString(64) - }).orNull(), + signer = signer, usesPermission = getRandomList { PermissionV2(getRandomString(), Random.nextInt().orNull()) }, From b28d2ecd5be0b88cef4353c578d40baf30a1a719 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 31 Oct 2023 10:53:15 -0300 Subject: [PATCH 06/33] [app] Only request updates and suggested versions from preferred repositories Before, we would install compatible updates from any repository --- .../java/org/fdroid/fdroid/AppUpdateStatusManager.java | 2 +- app/src/main/java/org/fdroid/fdroid/UpdateService.java | 2 +- .../fdroid/views/apps/AppListItemController.java | 10 ++++++---- .../fdroid/fdroid/views/updates/UpdatesAdapter.java | 2 +- app/src/main/res/values/strings.xml | 1 + .../main/java/org/fdroid/database/DbUpdateChecker.kt | 7 +++++++ 6 files changed, 17 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java b/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java index dbb755aa0..0b07367f2 100644 --- a/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java +++ b/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java @@ -376,7 +376,7 @@ public final class AppUpdateStatusManager { @WorkerThread private List getUpdatableApps() { List releaseChannels = Preferences.get().getBackendReleaseChannels(); - return updateChecker.getUpdatableApps(releaseChannels); + return updateChecker.getUpdatableApps(releaseChannels, true); } private void addUpdatableApps(@Nullable List canUpdate) { diff --git a/app/src/main/java/org/fdroid/fdroid/UpdateService.java b/app/src/main/java/org/fdroid/fdroid/UpdateService.java index 5ee6a8d6e..d7331603b 100644 --- a/app/src/main/java/org/fdroid/fdroid/UpdateService.java +++ b/app/src/main/java/org/fdroid/fdroid/UpdateService.java @@ -532,7 +532,7 @@ public class UpdateService extends JobIntentService { public static Disposable autoDownloadUpdates(Context context) { DbUpdateChecker updateChecker = new DbUpdateChecker(DBHelper.getDb(context), context.getPackageManager()); List releaseChannels = Preferences.get().getBackendReleaseChannels(); - return Single.fromCallable(() -> updateChecker.getUpdatableApps(releaseChannels)) + return Single.fromCallable(() -> updateChecker.getUpdatableApps(releaseChannels, true)) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .doOnError(throwable -> Log.e(TAG, "Error auto-downloading updates: ", throwable)) diff --git a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java index 280ac87fe..62fbabcca 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java +++ b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java @@ -549,13 +549,15 @@ public abstract class AppListItemController extends RecyclerView.ViewHolder { if (disposable != null) disposable.dispose(); disposable = Utils.runOffUiThread(() -> { AppVersion version = updateChecker.getSuggestedVersion(app.packageName, - app.preferredSigner, releaseChannels); - if (version == null) return null; + app.preferredSigner, releaseChannels, true); + if (version == null) return new Apk(); Repository repo = FDroidApp.getRepoManager(activity).getRepository(version.getRepoId()); - if (repo == null) return null; + if (repo == null) return new Apk(); return new Apk(version, repo); }, receivedApk -> { - if (receivedApk != null) { + if (receivedApk.packageName == null) { + Toast.makeText(activity, R.string.app_list_no_suggested_version, Toast.LENGTH_SHORT).show(); + } else { String canonicalUrl = receivedApk.getCanonicalUrl(); Uri canonicalUri = Uri.parse(canonicalUrl); broadcastManager.registerReceiver(receiver, diff --git a/app/src/main/java/org/fdroid/fdroid/views/updates/UpdatesAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/updates/UpdatesAdapter.java index 2e8cb55b9..495536b81 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/updates/UpdatesAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/updates/UpdatesAdapter.java @@ -112,7 +112,7 @@ public class UpdatesAdapter extends RecyclerView.Adapter releaseChannels = Preferences.get().getBackendReleaseChannels(); if (disposable != null) disposable.dispose(); - disposable = Utils.runOffUiThread(() -> updateChecker.getUpdatableApps(releaseChannels, true), + disposable = Utils.runOffUiThread(() -> updateChecker.getUpdatableApps(releaseChannels, true, true), this::onCanUpdateLoadFinished); } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9f0ca13a0..e107651a6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -131,6 +131,7 @@ This often occurs with apps installed via Google Play or other sources, if they Could not launch app. Some results were hidden based on your antifeature settings. + No version recommended for installation. Downloading %1$s %1$s installed Downloaded, ready to install diff --git a/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt b/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt index f77c1b4e7..b763e92be 100644 --- a/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt +++ b/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt @@ -25,6 +25,10 @@ public class DbUpdateChecker @JvmOverloads constructor( * Returns a list of apps that can be updated. * @param releaseChannels optional list of release channels to consider on top of stable. * If this is null or empty, only versions without channel (stable) will be considered. + * @param onlyFromPreferredRepo if true updates coming from repositories that are not preferred, + * either via [AppPrefs.preferredRepoId] or [Repository.weight] will not be returned. + * If false, updates from all enabled repositories will be considered + * and the one with the highest version code returned. */ @JvmOverloads public fun getUpdatableApps( @@ -78,6 +82,9 @@ public class DbUpdateChecker @JvmOverloads constructor( * or null if there is none. * @param releaseChannels optional list of release channels to consider on top of stable. * If this is null or empty, only versions without channel (stable) will be considered. + * @param onlyFromPreferredRepo if true a version from a repository that is not preferred, + * either via [AppPrefs.preferredRepoId] or [Repository.weight] will not be returned. + * If false, versions from all enabled repositories will be considered. */ @SuppressLint("PackageManagerGetSignatures") public fun getSuggestedVersion( From 4a01b02fa6d6b9984f98511e52e12aeecaa512d1 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 31 Oct 2023 13:13:38 -0300 Subject: [PATCH 07/33] [db] Add DB query for getting repos an app is in --- .../java/org/fdroid/database/AppDaoTest.kt | 20 +++++++++++++++++++ .../main/java/org/fdroid/database/AppDao.kt | 11 ++++++++++ 2 files changed, 31 insertions(+) diff --git a/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt index 77e09654b..1b19435db 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt @@ -108,6 +108,26 @@ internal class AppDaoTest : AppTest() { assertEquals(app1, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort()) } + @Test + fun testGetRepositoryIdsForApp() { + // initially, the app is in no repos + assertEquals(emptyList(), appDao.getRepositoryIdsForApp(packageName)) + + // insert same app into one repo + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId1, packageName, app1, locales) + assertEquals(listOf(repoId1), appDao.getRepositoryIdsForApp(packageName)) + + // insert the app into one more repo + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId2, packageName, app2, locales) + assertEquals(listOf(repoId1, repoId2), appDao.getRepositoryIdsForApp(packageName)) + + // when repo1 is disabled, it doesn't get returned anymore + repoDao.setRepositoryEnabled(repoId1, false) + assertEquals(listOf(repoId2), appDao.getRepositoryIdsForApp(packageName)) + } + @Test fun testUpdateCompatibility() { // insert two apps with one version each diff --git a/libs/database/src/main/java/org/fdroid/database/AppDao.kt b/libs/database/src/main/java/org/fdroid/database/AppDao.kt index 6a17deeb5..92c2240cd 100644 --- a/libs/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/AppDao.kt @@ -70,6 +70,12 @@ public interface AppDao { */ public fun getApp(repoId: Long, packageName: String): App? + /** + * Returns a list of all enabled repositories identified by their [Repository.repoId] + * that contain the app identified by the given [packageName]. + */ + public fun getRepositoryIdsForApp(packageName: String): List + /** * Returns a limited number of apps with limited data. * Apps without name, icon or summary are at the end (or excluded if limit is too small). @@ -316,6 +322,11 @@ internal interface AppDaoInt : AppDao { WHERE repoId = :repoId AND packageName = :packageName""") override fun getApp(repoId: Long, packageName: String): App? + @Query("""SELECT repoId FROM ${AppMetadata.TABLE} + JOIN RepositoryPreferences AS pref USING (repoId) + WHERE pref.enabled = 1 AND packageName = :packageName""") + override fun getRepositoryIdsForApp(packageName: String): List + /** * Used for diffing. */ From d9ea1e154b25ec21e5d55e23674140eb49e0a6c7 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 2 Nov 2023 10:23:40 -0300 Subject: [PATCH 08/33] [db] Migrate repo weights and test migration --- gradle/verification-metadata.xml | 5 + libs/database/build.gradle | 7 + .../fdroid/database/MultiRepoMigrationTest.kt | 284 ++++++++++++++++++ .../org/fdroid/database/FDroidDatabase.kt | 5 +- .../java/org/fdroid/database/Migrations.kt | 106 +++++++ 5 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 libs/database/src/dbTest/java/org/fdroid/database/MultiRepoMigrationTest.kt create mode 100644 libs/database/src/main/java/org/fdroid/database/Migrations.kt diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 869d28066..439ac43ca 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -1514,6 +1514,11 @@ + + + + + diff --git a/libs/database/build.gradle b/libs/database/build.gradle index 75a8afe2a..e6ee2490a 100644 --- a/libs/database/build.gradle +++ b/libs/database/build.gradle @@ -12,6 +12,7 @@ android { defaultConfig { minSdkVersion 21 + targetSdk 33 // relevant for instrumentation tests (targetSdk 21 fails on Android 14) consumerProguardFiles "consumer-rules.pro" javaCompileOptions { @@ -32,9 +33,13 @@ android { sourceSets { androidTest { java.srcDirs += "src/dbTest/java" + // Adds exported schema location as test app assets. + assets.srcDirs += files("$projectDir/schemas".toString()) } test { java.srcDirs += "src/dbTest/java" + // Adds exported schema location as test app assets. + assets.srcDirs += files("$projectDir/schemas".toString()) } } compileOptions { @@ -84,6 +89,7 @@ dependencies { testImplementation 'androidx.test:core:1.5.0' testImplementation 'androidx.test.ext:junit:1.1.5' testImplementation 'androidx.arch.core:core-testing:2.2.0' + testImplementation "androidx.room:room-testing:2.5.2" testImplementation 'org.robolectric:robolectric:4.10.3' testImplementation 'commons-io:commons-io:2.6' testImplementation 'ch.qos.logback:logback-classic:1.4.5' @@ -97,6 +103,7 @@ dependencies { androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.arch.core:core-testing:2.2.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation "androidx.room:room-testing:2.5.2" androidTestImplementation 'commons-io:commons-io:2.6' } diff --git a/libs/database/src/dbTest/java/org/fdroid/database/MultiRepoMigrationTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/MultiRepoMigrationTest.kt new file mode 100644 index 000000000..c5b89696e --- /dev/null +++ b/libs/database/src/dbTest/java/org/fdroid/database/MultiRepoMigrationTest.kt @@ -0,0 +1,284 @@ +package org.fdroid.database + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase.CONFLICT_FAIL +import androidx.room.Room.databaseBuilder +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.fdroid.database.Converters.localizedTextV2toString +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.fail + +private const val TEST_DB = "migration-test" + +@RunWith(AndroidJUnit4::class) +internal class MultiRepoMigrationTest { + + @get:Rule + val helper: MigrationTestHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + FDroidDatabaseInt::class.java, + listOf(MultiRepoMigration()), + FrameworkSQLiteOpenHelperFactory(), + ) + + private val fdroidArchiveRepo = InitialRepository( + name = "F-Droid Archive", + address = "https://f-droid.org/archive", + description = "The archive repository of the F-Droid client. " + + "This contains older versions of\n" + + "applications from the main repository.", + certificate = "3082035e30820246a00302010202044c49cd00300d06092a864886f70d010105", + version = 13L, + enabled = false, + weight = 0, // gets set later + ) + private val fdroidRepo = InitialRepository( + name = "F-Droid", + address = "https://f-droid.org/repo", + description = "The official F-Droid Free Software repository. " + + "Everything in this repository is always built from the source code.", + certificate = "3082035e30820246a00302010202044c49cd00300d06092a864886f70d010105", + version = 13L, + enabled = true, + weight = 0, // gets set later + ) + private val guardianArchiveRepo = InitialRepository( + name = "Guardian Project Archive", + address = "https://guardianproject.info/fdroid/archive", + description = "The official repository of The Guardian Project apps" + + " for use with F-Droid client. This\n" + + " contains older versions of applications from the main repository.\n", + certificate = "308205d8308203c0020900a397b4da7ecda034300d06092a864886f70d010105", + version = 13L, + enabled = false, + weight = 0, // gets set later + ) + private val guardianRepo = InitialRepository( + name = "Guardian Project", + address = "https://guardianproject.info/fdroid/repo", + description = "The official app repository of The Guardian Project. " + + "Applications in this repository\n" + + " are official binaries build by the original application developers " + + "and signed by the\n" + + " same key as the APKs that are released in the Google Play store.", + certificate = "308205d8308203c0020900a397b4da7ecda034300d06092a864886f70d010105", + version = 13L, + enabled = false, + weight = 0, // gets set later + ) + + @Test + fun migrateDefaultRepos() { + val reposToMigrate = listOf( + fdroidArchiveRepo.copy(weight = 1), + fdroidRepo.copy(weight = 2), + ) + runRepoMigration(reposToMigrate) { db -> + db.getRepositoryDao().getRepositories().sortedByDescending { it.weight }.also { repos -> + assertEquals(reposToMigrate.size, repos.size) + assertEquals(reposToMigrate.size, repos.map { it.weight }.toSet().size) + assertEquals(fdroidRepo.address, repos[0].address) + assertEquals(1_000_000_000, repos[0].weight) + assertEquals(fdroidArchiveRepo.address, repos[1].address) + assertEquals(999_999_999, repos[1].weight) + } + } + } + + @Test + fun migrateOldDefaultRepos() { + val reposToMigrate = listOf( + fdroidArchiveRepo.copy(weight = 1), + fdroidRepo.copy(weight = 2), + guardianArchiveRepo.copy(weight = 3), + guardianRepo.copy(weight = 4), + ) + runRepoMigration(reposToMigrate) { db -> + db.getRepositoryDao().getRepositories().sortedByDescending { it.weight }.also { repos -> + assertEquals(reposToMigrate.size, repos.size) + assertEquals(reposToMigrate.size, repos.map { it.weight }.toSet().size) + assertEquals(fdroidRepo.address, repos[0].address) + assertEquals(1_000_000_000, repos[0].weight) + assertEquals(fdroidArchiveRepo.address, repos[1].address) + assertEquals(999_999_999, repos[1].weight) + assertEquals(guardianRepo.address, repos[2].address) + assertEquals(999_999_998, repos[2].weight) + assertEquals(guardianArchiveRepo.address, repos[3].address) + assertEquals(999_999_997, repos[3].weight) + } + } + } + + @Test + fun migrateOldDefaultReposPlusRandomOnes() { + val reposToMigrate = listOf( + fdroidArchiveRepo.copy(weight = 1), + fdroidRepo.copy(weight = 2), + guardianArchiveRepo.copy(weight = 3), + guardianRepo.copy(weight = 4), + InitialRepository( + name = "Foo bar", + address = "https://example.org/fdroid/repo", + description = "foo bar repo", + certificate = "1234567890", + version = 0L, + enabled = true, + weight = 5, + ), + InitialRepository( + name = "Bla Blub", + address = "https://example.com/fdroid/repo", + description = "bla blub repo", + certificate = "0987654321", + version = 0L, + enabled = true, + weight = 6, + ), + ) + runRepoMigration(reposToMigrate) { db -> + db.getRepositoryDao().getRepositories().sortedByDescending { it.weight }.also { repos -> + assertEquals(reposToMigrate.size, repos.size) + assertEquals(reposToMigrate.size, repos.map { it.weight }.toSet().size) + assertEquals(fdroidRepo.address, repos[0].address) + assertEquals(1_000_000_000, repos[0].weight) + assertEquals(fdroidArchiveRepo.address, repos[1].address) + assertEquals(999_999_999, repos[1].weight) + assertEquals(guardianRepo.address, repos[2].address) + assertEquals(999_999_998, repos[2].weight) + assertEquals(guardianArchiveRepo.address, repos[3].address) + assertEquals(999_999_997, repos[3].weight) + assertEquals("https://example.org/fdroid/repo", repos[4].address) + assertEquals(999_999_996, repos[4].weight) + assertEquals("https://example.com/fdroid/repo", repos[5].address) + assertEquals(999_999_994, repos[5].weight) // space for archive above + } + } + } + + @Test + fun migrateArchiveWithoutMainRepo() { + val reposToMigrate = listOf( + InitialRepository( + name = "Foo bar", + address = "https://example.org/fdroid/repo", + description = "foo bar repo", + certificate = "1234567890", + version = 0L, + enabled = true, + weight = 2, + ), + fdroidArchiveRepo.copy(weight = 5), + guardianRepo.copy(weight = 6), + ) + runRepoMigration(reposToMigrate) { db -> + db.getRepositoryDao().getRepositories().sortedByDescending { it.weight }.also { repos -> + assertEquals(reposToMigrate.size, repos.size) + assertEquals(reposToMigrate.size, repos.map { it.weight }.toSet().size) + assertEquals("https://example.org/fdroid/repo", repos[0].address) + assertEquals(1_000_000_000, repos[0].weight) + assertEquals(guardianRepo.address, repos[1].address) + assertEquals(999_999_998, repos[1].weight) // space for archive above + assertEquals(fdroidArchiveRepo.address, repos[2].address) + assertEquals(999_999_996, repos[2].weight) // space for archive above + } + } + } + + @Test + fun testPreferredRepoChanges() { + var repoId: Long + val packageName = "org.example" + helper.createDatabase(TEST_DB, 1).use { db -> + // Database has schema version 1. Insert some data using SQL queries. + // We can't use DAO classes because they expect the latest schema. + val repo = fdroidRepo + repoId = db.insert(CoreRepository.TABLE, CONFLICT_FAIL, ContentValues().apply { + put("name", localizedTextV2toString(mapOf("en-US" to repo.name))) + put("description", localizedTextV2toString(mapOf("en-US" to repo.description))) + put("address", repo.address) + put("timestamp", -1) + put("certificate", repo.certificate) + }) + db.insert(RepositoryPreferences.TABLE, CONFLICT_FAIL, ContentValues().apply { + put("repoId", repoId) + put("enabled", repo.enabled) + put("weight", repo.weight) + }) + // insert an app with empty app prefs + db.insert(AppMetadata.TABLE, CONFLICT_FAIL, ContentValues().apply { + put("repoId", repoId) + put("packageName", packageName) + put("added", 23L) + put("lastUpdated", 42L) + put("isCompatible", true) + }) + db.insert(AppPrefs.TABLE, CONFLICT_FAIL, ContentValues().apply { + put("packageName", packageName) + put("ignoreVersionCodeUpdate", 0) + }) + } + + // Re-open the database with version 2, auto-migrations are applied automatically + helper.runMigrationsAndValidate(TEST_DB, 2, true).close() + + // now get the Room DB, so we can use our DAOs for verifying the migration + databaseBuilder(getApplicationContext(), FDroidDatabaseInt::class.java, TEST_DB) + .allowMainThreadQueries() + .build() + .use { db -> + // migrated apps have no preferred repo set + assertNotNull(db.getAppDao().getApp(repoId, packageName)) + val appPrefs = db.getAppPrefsDao().getAppPrefsOrNull(packageName) ?: fail() + assertEquals(packageName, appPrefs.packageName) + assertNull(appPrefs.preferredRepoId) + + // preferred repo inferred from repo priorities + val preferredRepos = db.getAppPrefsDao().getPreferredRepos(listOf(packageName)) + assertEquals(1, preferredRepos.size) + assertEquals(repoId, preferredRepos[packageName]) + } + } + + private fun runRepoMigration( + repos: List, + check: (FDroidDatabaseInt) -> Unit, + ) { + helper.createDatabase(TEST_DB, 1).use { db -> + // Database has schema version 1. Insert some data using SQL queries. + // We can't use DAO classes because they expect the latest schema. + repos.forEach { repo -> + val repoId = db.insert(CoreRepository.TABLE, CONFLICT_FAIL, ContentValues().apply { + put("name", localizedTextV2toString(mapOf("en-US" to repo.name))) + put("description", localizedTextV2toString(mapOf("en-US" to repo.description))) + put("address", repo.address) + put("timestamp", -1) + put("certificate", repo.certificate) + }) + db.insert(RepositoryPreferences.TABLE, CONFLICT_FAIL, ContentValues().apply { + put("repoId", repoId) + put("enabled", repo.enabled) + put("weight", repo.weight) + }) + } + } + + // Re-open the database with version 2, auto-migrations are applied automatically + helper.runMigrationsAndValidate(TEST_DB, 2, true).close() + + // now get the Room DB, so we can use our DAOs for verifying the migration + databaseBuilder(getApplicationContext(), FDroidDatabaseInt::class.java, TEST_DB) + .allowMainThreadQueries() + .build().use { db -> + check(db) + } + } +} diff --git a/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index d44cf28a1..5f586a30c 100644 --- a/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -8,6 +8,7 @@ import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters import org.fdroid.LocaleChooser.getBestLocale +import java.io.Closeable import java.util.Locale import java.util.concurrent.Callable @@ -42,12 +43,12 @@ import java.util.concurrent.Callable ], exportSchema = true, autoMigrations = [ + AutoMigration(1, 2, MultiRepoMigration::class), // add future migrations here (if they are easy enough to be done automatically) - AutoMigration(1, 2), ], ) @TypeConverters(Converters::class) -internal abstract class FDroidDatabaseInt internal constructor() : RoomDatabase(), FDroidDatabase { +internal abstract class FDroidDatabaseInt : RoomDatabase(), FDroidDatabase, Closeable { abstract override fun getRepositoryDao(): RepositoryDaoInt abstract override fun getAppDao(): AppDaoInt abstract override fun getVersionDao(): VersionDaoInt diff --git a/libs/database/src/main/java/org/fdroid/database/Migrations.kt b/libs/database/src/main/java/org/fdroid/database/Migrations.kt new file mode 100644 index 000000000..3e326eb9d --- /dev/null +++ b/libs/database/src/main/java/org/fdroid/database/Migrations.kt @@ -0,0 +1,106 @@ +package org.fdroid.database + +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase.CONFLICT_FAIL +import androidx.room.migration.AutoMigrationSpec +import androidx.sqlite.db.SupportSQLiteDatabase +import mu.KotlinLogging + +private const val REPO_WEIGHT = 1_000_000_000 + +internal class MultiRepoMigration : AutoMigrationSpec { + + private val log = KotlinLogging.logger {} + + override fun onPostMigrate(db: SupportSQLiteDatabase) { + super.onPostMigrate(db) + // do migration in one transaction that can be rolled back if there's issues + db.beginTransaction() + try { + migrateWeights(db) + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + private fun migrateWeights(db: SupportSQLiteDatabase) { + // get repositories + val repos = ArrayList() + val archiveMap = HashMap() + db.query( + """ + SELECT repoId, address, certificate, weight FROM ${CoreRepository.TABLE} + JOIN ${RepositoryPreferences.TABLE} USING (repoId) + ORDER BY weight ASC""" + ).use { cursor -> + while (cursor.moveToNext()) { + val repo = getRepo(cursor) + log.error { repo.toString() } + if (repo.isArchive()) { + if (archiveMap.containsKey(repo.certificate)) { + log.error { "More than two repos with certificate of ${repo.address}" } + // still migrating this as a normal repo then + repos.add(repo) + } else { + // remember archive repo, so we get position it below main repo + archiveMap[repo.certificate] = repo + } + } else { + repos.add(repo) + } + } + } + + // now go through all repos and adapt their weight, + // so that repos with a low weight get a high weight with space for archive repos + var nextWeight = REPO_WEIGHT + repos.forEach { repo -> + val archiveRepo = archiveMap[repo.certificate] + if (archiveRepo == null) { + db.updateRepoWeight(repo, nextWeight) + } else { + db.updateRepoWeight(repo, nextWeight) + db.updateRepoWeight(archiveRepo, nextWeight - 1) + archiveMap.remove(repo.certificate) + } + nextWeight -= 2 + } + // going through archive repos without main repo as well and put them at the end + // so they don't get stuck with minimum weights + archiveMap.forEach { (_, repo) -> + db.updateRepoWeight(repo, nextWeight) + nextWeight -= 1 + } + } + + private fun SupportSQLiteDatabase.updateRepoWeight(repo: Repo, newWeight: Int) { + val rowsAffected = update( + table = RepositoryPreferences.TABLE, + conflictAlgorithm = CONFLICT_FAIL, + values = ContentValues(1).apply { + put("weight", newWeight) + }, + whereClause = "repoId = ?", + whereArgs = arrayOf(repo.repoId), + ) + if (rowsAffected > 1) error("repo ${repo.address} had more than one preference") + } + + private fun getRepo(c: Cursor) = Repo( + repoId = c.getLong(0), + address = c.getString(1), + certificate = c.getString(2), + weight = c.getInt(3), + ) + + private data class Repo( + val repoId: Long, + val address: String, + val certificate: String, + val weight: Int, + ) { + fun isArchive(): Boolean = address.trimEnd('/').endsWith("/archive") + } +} From b993da8db8fb053e6d73d2676af23845f44fe9eb Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 3 Nov 2023 11:22:42 -0300 Subject: [PATCH 09/33] [db] New repos now get lower weight than older ones so they do not override the information from older repos anymore. This is especially an issue for the official repo which historically had the lowest priority while it should have the highest. --- .../java/org/fdroid/database/AppDaoTest.kt | 10 +++---- .../org/fdroid/database/AppListItemsTest.kt | 15 ++++------- .../fdroid/database/AppOverviewItemsTest.kt | 27 +++++++++++++++---- .../org/fdroid/database/AppPrefsDaoTest.kt | 4 +-- .../fdroid/database/DbUpdateCheckerTest.kt | 6 ++--- .../org/fdroid/database/RepositoryDaoTest.kt | 13 ++++++++- .../main/java/org/fdroid/database/AppDao.kt | 2 +- .../java/org/fdroid/database/RepositoryDao.kt | 16 +++++------ .../java/org/fdroid/repo/RepoAdderTest.kt | 2 +- 9 files changed, 59 insertions(+), 36 deletions(-) diff --git a/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt index 1b19435db..0c1ebe25a 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt @@ -35,9 +35,9 @@ internal class AppDaoTest : AppTest() { @Test fun testGetSameAppFromTwoRepos() { // insert same app into three repos (repoId1 has highest weight) - val repoId2 = repoDao.insertOrReplace(getRandomRepo()) - val repoId3 = repoDao.insertOrReplace(getRandomRepo()) val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) appDao.insert(repoId1, packageName, app1, locales) appDao.insert(repoId2, packageName, app2, locales) appDao.insert(repoId3, packageName, app3, locales) @@ -67,9 +67,9 @@ internal class AppDaoTest : AppTest() { @Test fun testAppRepoPref() { // insert same app into three repos (repoId1 has highest weight) - val repoId2 = repoDao.insertOrReplace(getRandomRepo()) - val repoId3 = repoDao.insertOrReplace(getRandomRepo()) val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) appDao.insert(repoId1, packageName, app1, locales) appDao.insert(repoId2, packageName, app2, locales) appDao.insert(repoId3, packageName, app3, locales) @@ -93,8 +93,8 @@ internal class AppDaoTest : AppTest() { @Test fun testGetSameAppFromTwoReposOneDisabled() { // insert same app into two repos (repoId2 has highest weight) - val repoId1 = repoDao.insertOrReplace(getRandomRepo()) val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) appDao.insert(repoId1, packageName, app1, locales) appDao.insert(repoId2, packageName, app2, locales) diff --git a/libs/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt index b7dce5948..f06839e93 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt @@ -25,6 +25,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.test.fail +@Suppress("DEPRECATION") @RunWith(AndroidJUnit4::class) internal class AppListItemsTest : AppTest() { @@ -48,7 +49,6 @@ internal class AppListItemsTest : AppTest() { appDao.insert(repoId, packageName2, app2, locales) // one of the apps is installed - @Suppress("DEPRECATION") val packageInfo2 = PackageInfo().apply { packageName = packageName2 versionName = getRandomString() @@ -108,7 +108,6 @@ internal class AppListItemsTest : AppTest() { appDao.insert(repoId, packageName2, app2, locales) // one of the apps is installed - @Suppress("DEPRECATION") val packageInfo2 = PackageInfo().apply { packageName = packageName2 versionName = getRandomString() @@ -177,7 +176,6 @@ internal class AppListItemsTest : AppTest() { appDao.insert(repoId3, packageName3, app3b, locales) // one of the apps is installed - @Suppress("DEPRECATION") val packageInfo2 = PackageInfo().apply { packageName = packageName2 versionName = getRandomString() @@ -306,7 +304,6 @@ internal class AppListItemsTest : AppTest() { appDao.insert(repoId, packageName2, app2, locales) // one of the apps is installed - @Suppress("DEPRECATION") val packageInfo2 = PackageInfo().apply { packageName = packageName2 versionName = getRandomString() @@ -429,9 +426,9 @@ internal class AppListItemsTest : AppTest() { @Test fun testFromRepoWithHighestWeight() { // insert same app into three repos (repoId1 has highest weight) - val repoId2 = repoDao.insertOrReplace(getRandomRepo()) - val repoId3 = repoDao.insertOrReplace(getRandomRepo()) val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) appDao.insert(repoId2, packageName, app2, locales) appDao.insert(repoId1, packageName, app1, locales) appDao.insert(repoId3, packageName, app3, locales) @@ -454,9 +451,9 @@ internal class AppListItemsTest : AppTest() { @Test fun testFromRepoFromAppPrefs() { // insert same app into three repos (repoId1 has highest weight) - val repoId2 = repoDao.insertOrReplace(getRandomRepo()) - val repoId3 = repoDao.insertOrReplace(getRandomRepo()) val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) appDao.insert(repoId2, packageName, app2, locales) appDao.insert(repoId1, packageName, app1, locales) appDao.insert(repoId3, packageName, app3, locales) @@ -539,7 +536,6 @@ internal class AppListItemsTest : AppTest() { appDao.insert(repoId, packageName3, app3, locales) // define packageInfo for each test - @Suppress("DEPRECATION") val packageInfo1 = PackageInfo().apply { packageName = packageName1 versionName = getRandomString() @@ -581,7 +577,6 @@ internal class AppListItemsTest : AppTest() { appDao.insert(repoId, packageName, app1, locales) val packageInfoCreator = { name: String -> - @Suppress("DEPRECATION") PackageInfo().apply { packageName = name versionName = name diff --git a/libs/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt index 25fc58f1a..b99234465 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt @@ -71,7 +71,14 @@ internal class AppOverviewItemsTest : AppTest() { val repoId2 = repoDao.insertOrReplace(getRandomRepo()) appDao.insert(repoId2, packageName, app2, locales) - // now icon is returned from app in second repo + // app is still returned as before + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app1.icon.getBestLocale(locales), apps[0].getIcon(locales)) + } + + // after preferring second repo, icon is returned from app in second repo + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId2)) appDao.getAppOverviewItems().getOrFail().let { apps -> assertEquals(1, apps.size) assertEquals(app2.icon.getBestLocale(locales), apps[0].getIcon(locales)) @@ -152,7 +159,14 @@ internal class AppOverviewItemsTest : AppTest() { val repoId2 = repoDao.insertOrReplace(getRandomRepo()) appDao.insert(repoId2, packageName, app2, locales) - // now second app from second repo is returned + // app is still returned as before, new repo doesn't override old one + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app1, apps[0]) + } + + // now second app from second repo is returned after preferring it explicitly + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId2)) appDao.getAppOverviewItems().getOrFail().let { apps -> assertEquals(1, apps.size) assertEquals(app2, apps[0]) @@ -162,9 +176,9 @@ internal class AppOverviewItemsTest : AppTest() { @Test fun testGetByRepoPref() { // insert same app into three repos (repoId1 has highest weight) - val repoId2 = repoDao.insertOrReplace(getRandomRepo()) - val repoId3 = repoDao.insertOrReplace(getRandomRepo()) val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) appDao.insert(repoId1, packageName, app1, locales) appDao.insert(repoId2, packageName, app2, locales) appDao.insert(repoId3, packageName, app3, locales) @@ -255,8 +269,10 @@ internal class AppOverviewItemsTest : AppTest() { assertEquals(3, appDao.getAppOverviewItems().getOrFail().size) // app3b is the same as app3, but has an icon, so is not last anymore + // after we prefer that repo for this app val app3b = app3.copy(icon = icons2) appDao.insert(repoId2, packageName3, app3b) + appPrefsDao.update(AppPrefs(packageName3, preferredRepoId = repoId2)) // note that we don't insert a version here appDao.getAppOverviewItems().getOrFail().let { apps -> assertEquals(3, apps.size) @@ -307,9 +323,10 @@ internal class AppOverviewItemsTest : AppTest() { // note that we don't insert a version here assertEquals(3, appDao.getAppOverviewItems("A").getOrFail().size) - // app3b is the same as app3, but has an icon, so is not last anymore + // app3b is the same as app3, but has an icon and is preferred, so is not last anymore val app3b = app3.copy(icon = icons2) appDao.insert(repoId2, packageName3, app3b) + appPrefsDao.update(AppPrefs(packageName3, preferredRepoId = repoId2)) // note that we don't insert a version here appDao.getAppOverviewItems("A").getOrFail().let { apps -> assertEquals(3, apps.size) diff --git a/libs/database/src/dbTest/java/org/fdroid/database/AppPrefsDaoTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppPrefsDaoTest.kt index b9d51c206..da767aa8b 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/AppPrefsDaoTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/AppPrefsDaoTest.kt @@ -55,9 +55,9 @@ internal class AppPrefsDaoTest : AppTest() { @Test fun testGetPreferredRepos() { // insert three apps, the third is in repo2 and repo3 - val repoId1 = repoDao.insertOrReplace(getRandomRepo()) - val repoId2 = repoDao.insertOrReplace(getRandomRepo()) val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) appDao.insert(repoId1, packageName1, app1, locales) appDao.insert(repoId2, packageName2, app2, locales) appDao.insert(repoId2, packageName3, app3, locales) diff --git a/libs/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt index 2f83e87ae..b846c3b90 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt @@ -136,8 +136,8 @@ internal class DbUpdateCheckerTest : AppTest() { @Test fun testSuggestedVersionOnlyFromPreferredRepo() { // insert the same app into two repos - val repoId1 = repoDao.insertOrReplace(getRandomRepo()) val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) appDao.insert(repoId1, packageName, app1, locales) appDao.insert(repoId2, packageName, app2, locales) @@ -225,9 +225,9 @@ internal class DbUpdateCheckerTest : AppTest() { @Test fun testGetUpdatableAppsOnlyFromPreferredRepo() { // insert the same app into three repos - val repoId1 = repoDao.insertOrReplace(getRandomRepo()) - val repoId2 = repoDao.insertOrReplace(getRandomRepo()) val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) appDao.insert(repoId1, packageInfo.packageName, app1, locales) appDao.insert(repoId2, packageInfo.packageName, app2, locales) appDao.insert(repoId3, packageInfo.packageName, app3, locales) diff --git a/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt index cde57bddf..5b337e02d 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt @@ -110,7 +110,7 @@ internal class RepositoryDaoTest : DbTest() { val repositoryPreferences2 = repoDao.getRepositoryPreferences(repoId2) assertEquals(repoId2, repositoryPreferences2?.repoId) // second repo has one weight point more than first repo - assertEquals(repositoryPreferences1?.weight?.plus(1), repositoryPreferences2?.weight) + assertEquals(repositoryPreferences1?.weight?.minus(2), repositoryPreferences2?.weight) // remove first repo and check that the database only returns one repoDao.deleteRepository(repoId1) @@ -277,4 +277,15 @@ internal class RepositoryDaoTest : DbTest() { assertEquals(1, repoDao.getRepositories().size) assertEquals(cert, repoDao.getRepositories()[0].certificate) } + + @Test + fun testGetMinRepositoryWeight() { + assertEquals(Int.MAX_VALUE, repoDao.getMinRepositoryWeight()) + + repoDao.insertOrReplace(getRandomRepo()) + assertEquals(Int.MAX_VALUE - 2, repoDao.getMinRepositoryWeight()) + + repoDao.insertOrReplace(getRandomRepo()) + assertEquals(Int.MAX_VALUE - 4, repoDao.getMinRepositoryWeight()) + } } diff --git a/libs/database/src/main/java/org/fdroid/database/AppDao.kt b/libs/database/src/main/java/org/fdroid/database/AppDao.kt index 92c2240cd..a5ada080a 100644 --- a/libs/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/AppDao.kt @@ -354,7 +354,7 @@ internal interface AppDaoInt : AppDao { JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) LEFT JOIN ${LocalizedIcon.TABLE} AS icon USING (repoId, packageName) - LEFT JOIN AppPrefs USING (packageName) + LEFT JOIN ${AppPrefs.TABLE} USING (packageName) WHERE pref.enabled = 1 AND COALESCE(preferredRepoId, repoId) = repoId GROUP BY packageName HAVING MAX(pref.weight) ORDER BY localizedName IS NULL ASC, icon.packageName IS NULL ASC, diff --git a/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt index 281d68122..7e2f9df97 100644 --- a/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -151,10 +151,10 @@ internal interface RepositoryDaoInt : RepositoryDao { certificate = newRepository.certificate, ) val repoId = insertOrReplace(repo) - val currentMaxWeight = getMaxRepositoryWeight() + val currentMinWeight = getMinRepositoryWeight() val repositoryPreferences = RepositoryPreferences( repoId = repoId, - weight = currentMaxWeight + 1, + weight = currentMinWeight - 2, lastUpdated = null, username = newRepository.username, password = newRepository.password, @@ -181,10 +181,10 @@ internal interface RepositoryDaoInt : RepositoryDao { certificate = null, ) val repoId = insertOrReplace(repo) - val currentMaxWeight = getMaxRepositoryWeight() + val currentMinWeight = getMinRepositoryWeight() val repositoryPreferences = RepositoryPreferences( repoId = repoId, - weight = currentMaxWeight + 1, + weight = currentMinWeight - 2, lastUpdated = null, username = username, password = password, @@ -197,15 +197,15 @@ internal interface RepositoryDaoInt : RepositoryDao { @VisibleForTesting fun insertOrReplace(repository: RepoV2, version: Long = 0): Long { val repoId = insertOrReplace(repository.toCoreRepository(version = version)) - val currentMaxWeight = getMaxRepositoryWeight() - val repositoryPreferences = RepositoryPreferences(repoId, currentMaxWeight + 1) + val currentMinWeight = getMinRepositoryWeight() + val repositoryPreferences = RepositoryPreferences(repoId, currentMinWeight - 2) insert(repositoryPreferences) insertRepoTables(repoId, repository) return repoId } - @Query("SELECT MAX(weight) FROM ${RepositoryPreferences.TABLE}") - fun getMaxRepositoryWeight(): Int + @Query("SELECT COALESCE(MIN(weight), ${Int.MAX_VALUE}) FROM ${RepositoryPreferences.TABLE}") + fun getMinRepositoryWeight(): Int @Transaction @Query("SELECT * FROM ${CoreRepository.TABLE} WHERE repoId = :repoId") diff --git a/libs/database/src/test/java/org/fdroid/repo/RepoAdderTest.kt b/libs/database/src/test/java/org/fdroid/repo/RepoAdderTest.kt index 714f7b5a2..eb58d8864 100644 --- a/libs/database/src/test/java/org/fdroid/repo/RepoAdderTest.kt +++ b/libs/database/src/test/java/org/fdroid/repo/RepoAdderTest.kt @@ -100,7 +100,7 @@ internal class RepoAdderTest { val userManager = mockk() val repoAdder = RepoAdder(context, db, tempFileProvider, downloaderFactory, httpManager) - every { context.getSystemService("user") } returns userManager + every { context.getSystemService(UserManager::class.java) } returns userManager every { userManager.hasUserRestriction(DISALLOW_INSTALL_UNKNOWN_SOURCES) } returns true From 51a4bcec5890dc45ddc8e94fe8bca2242cfdc6c7 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 3 Nov 2023 11:30:44 -0300 Subject: [PATCH 10/33] [db] don't allow initial repos from fixtures to provide weight The client was already auto-incrementing their weight anyway. But it leaked our internal weight handling into the library consumer which can cause issues like when me are making changes to how we handles repo weights as we are doing now. --- .../src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt | 3 +-- libs/database/src/main/java/org/fdroid/database/Repository.kt | 3 ++- .../src/main/java/org/fdroid/database/RepositoryDao.kt | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt index 5b337e02d..1d2c6ac4c 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt @@ -30,7 +30,6 @@ internal class RepositoryDaoTest : DbTest() { certificate = "abcdef", // not random, because format gets checked version = Random.nextLong(), enabled = Random.nextBoolean(), - weight = Random.nextInt(), ) val repoId = repoDao.insert(repo) @@ -41,7 +40,7 @@ internal class RepositoryDaoTest : DbTest() { assertEquals(repo.certificate, actualRepo.certificate) assertEquals(repo.version, actualRepo.version) assertEquals(repo.enabled, actualRepo.enabled) - assertEquals(repo.weight, actualRepo.weight) + assertEquals(Int.MAX_VALUE - 2, actualRepo.weight) // ignoring provided weight assertEquals(-1, actualRepo.timestamp) assertEquals(3, actualRepo.mirrors.size) assertEquals(emptyList(), actualRepo.userMirrors) diff --git a/libs/database/src/main/java/org/fdroid/database/Repository.kt b/libs/database/src/main/java/org/fdroid/database/Repository.kt index 668bf9905..19b173cba 100644 --- a/libs/database/src/main/java/org/fdroid/database/Repository.kt +++ b/libs/database/src/main/java/org/fdroid/database/Repository.kt @@ -393,7 +393,8 @@ public data class InitialRepository @JvmOverloads constructor( val certificate: String, val version: Long, val enabled: Boolean, - val weight: Int, + @Deprecated("This is automatically assigned now and can be safely removed.") + val weight: Int = 0, // still used for testing, could be made internal or tests migrate away ) { init { validateCertificate(certificate) diff --git a/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt index 7e2f9df97..929d4f344 100644 --- a/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -127,9 +127,10 @@ internal interface RepositoryDaoInt : RepositoryDao { certificate = initialRepo.certificate, ) val repoId = insertOrReplace(repo) + val currentMinWeight = getMinRepositoryWeight() val repositoryPreferences = RepositoryPreferences( repoId = repoId, - weight = initialRepo.weight, + weight = currentMinWeight - 2, lastUpdated = null, enabled = initialRepo.enabled, ) From 0bfc0b105902e79b74c2802c9a043ff22abe2603 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 3 Nov 2023 11:32:08 -0300 Subject: [PATCH 11/33] [app] stop assigning a weight to repos as the DB handles this now therefore the order of the default repos is reversed, so the archive repos has a lower weight than the official repo --- .../fdroid/data/ContentProviderMigrator.java | 3 +- .../java/org/fdroid/fdroid/data/DBHelper.java | 4 +- app/src/main/res/values/default_repos.xml | 77 ++++++++++--------- 3 files changed, 41 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/data/ContentProviderMigrator.java b/app/src/main/java/org/fdroid/fdroid/data/ContentProviderMigrator.java index 1403f01fc..29c3ceaf3 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/ContentProviderMigrator.java +++ b/app/src/main/java/org/fdroid/fdroid/data/ContentProviderMigrator.java @@ -42,7 +42,6 @@ final class ContentProviderMigrator { private void migrateOldRepos(FDroidDatabase db, SQLiteDatabase oldDb) { RepositoryDao repoDao = db.getRepositoryDao(); List repos = repoDao.getRepositories(); - int weight = repos.isEmpty() ? 0 : repos.get(repos.size() - 1).getWeight(); String[] projection = new String[] { @@ -81,7 +80,7 @@ final class ContentProviderMigrator { // add new repo if not existing if (repo == null) { // new repo to be added to new DB InitialRepository newRepo = new InitialRepository(name, address, "", certificate, - 0, enabled, ++weight); + 0, enabled); long repoId = repoDao.insert(newRepo); repo = ObjectsCompat.requireNonNull(repoDao.getRepository(repoId)); } else { // old repo that may need an update for the new DB diff --git a/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java b/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java index 8f0c80a39..f90deccc3 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java +++ b/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java @@ -71,7 +71,6 @@ public class DBHelper { @VisibleForTesting static void prePopulateDb(Context context, FDroidDatabase db) { List initialRepos = DBHelper.loadInitialRepos(context); - int weight = 1; boolean hasEnabledRepo = false; for (int i = 0; i < initialRepos.size(); i += REPO_XML_ITEM_COUNT) { boolean enabled = initialRepos.get(i + 4).equals("1"); @@ -91,8 +90,7 @@ public class DBHelper { initialRepos.get(i + 2), // description initialRepos.get(i + 6), // certificate Integer.parseInt(initialRepos.get(i + 3)), // version - enabled, // enabled - weight++ // weight + enabled // enabled ); } catch (IllegalArgumentException e) { Log.e(TAG, "Invalid repo: " + addresses.get(0), e); diff --git a/app/src/main/res/values/default_repos.xml b/app/src/main/res/values/default_repos.xml index f2dab7308..4b2bcaee1 100644 --- a/app/src/main/res/values/default_repos.xml +++ b/app/src/main/res/values/default_repos.xml @@ -1,8 +1,47 @@ + + + F-Droid + + + https://f-droid.org/repo + http://fdroidorg6cooksyluodepej4erfctzk7rrjpjbbr6wx24jh3lqyfwyd.onion/fdroid/repo + http://dotsrccccbidkzg7oc7oj4ugxrlfbt64qebyunxbrgqhxiwj3nl6vcad.onion/fdroid/repo + http://ftpfaudev4triw2vxiwzf4334e3mynz7osqgtozhbc77fixncqzbyoyd.onion/fdroid/repo + http://lysator7eknrfl47rlyxvgeamrv7ucefgrrlhk7rouv3sna25asetwid.onion/pub/fdroid/repo + http://mirror.ossplanetnyou5xifr6liw5vhzwc2g2fmmlohza25wwgnnaw65ytfsad.onion/fdroid/repo + https://fdroid.tetaneutral.net/fdroid/repo + https://ftp.agdsn.de/fdroid/repo + https://ftp.fau.de/fdroid/repo + https://ftp.gwdg.de/pub/android/fdroid/repo + https://ftp.lysator.liu.se/pub/fdroid/repo + https://mirror.cyberbits.eu/fdroid/repo + https://mirror.fcix.net/fdroid/repo + https://mirror.kumi.systems/fdroid/repo + https://mirror.level66.network/fdroid/repo + https://mirror.ossplanet.net/fdroid/repo + https://mirrors.dotsrc.org/fdroid/repo + https://opencolo.mm.fcix.net/fdroid/repo + https://plug-mirror.rcac.purdue.edu/fdroid/repo + + + The official F-Droid Free Software repository. Everything in this repository is always built from the source code. + + + 13 + + 1 + + ignore + + + 3082035e30820246a00302010202044c49cd00300d06092a864886f70d01010505003071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b73301e170d3130303732333137313032345a170d3337313230383137313032345a3071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b7330820122300d06092a864886f70d01010105000382010f003082010a028201010096d075e47c014e7822c89fd67f795d23203e2a8843f53ba4e6b1bf5f2fd0e225938267cfcae7fbf4fe596346afbaf4070fdb91f66fbcdf2348a3d92430502824f80517b156fab00809bdc8e631bfa9afd42d9045ab5fd6d28d9e140afc1300917b19b7c6c4df4a494cf1f7cb4a63c80d734265d735af9e4f09455f427aa65a53563f87b336ca2c19d244fcbba617ba0b19e56ed34afe0b253ab91e2fdb1271f1b9e3c3232027ed8862a112f0706e234cf236914b939bcf959821ecb2a6c18057e070de3428046d94b175e1d89bd795e535499a091f5bc65a79d539a8d43891ec504058acb28c08393b5718b57600a211e803f4a634e5c57f25b9b8c4422c6fd90203010001300d06092a864886f70d0101050500038201010008e4ef699e9807677ff56753da73efb2390d5ae2c17e4db691d5df7a7b60fc071ae509c5414be7d5da74df2811e83d3668c4a0b1abc84b9fa7d96b4cdf30bba68517ad2a93e233b042972ac0553a4801c9ebe07bf57ebe9a3b3d6d663965260e50f3b8f46db0531761e60340a2bddc3426098397fda54044a17e5244549f9869b460ca5e6e216b6f6a2db0580b480ca2afe6ec6b46eedacfa4aa45038809ece0c5978653d6c85f678e7f5a2156d1bedd8117751e64a4b0dcd140f3040b021821a8d93aed8d01ba36db6c82372211fed714d9a32607038cdfd565bd529ffc637212aaa2c224ef22b603eccefb5bf1e085c191d4b24fe742b17ab3f55d4e6f05ef + + F-Droid Archive @@ -42,44 +81,6 @@ 3082035e30820246a00302010202044c49cd00300d06092a864886f70d01010505003071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b73301e170d3130303732333137313032345a170d3337313230383137313032345a3071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b7330820122300d06092a864886f70d01010105000382010f003082010a028201010096d075e47c014e7822c89fd67f795d23203e2a8843f53ba4e6b1bf5f2fd0e225938267cfcae7fbf4fe596346afbaf4070fdb91f66fbcdf2348a3d92430502824f80517b156fab00809bdc8e631bfa9afd42d9045ab5fd6d28d9e140afc1300917b19b7c6c4df4a494cf1f7cb4a63c80d734265d735af9e4f09455f427aa65a53563f87b336ca2c19d244fcbba617ba0b19e56ed34afe0b253ab91e2fdb1271f1b9e3c3232027ed8862a112f0706e234cf236914b939bcf959821ecb2a6c18057e070de3428046d94b175e1d89bd795e535499a091f5bc65a79d539a8d43891ec504058acb28c08393b5718b57600a211e803f4a634e5c57f25b9b8c4422c6fd90203010001300d06092a864886f70d0101050500038201010008e4ef699e9807677ff56753da73efb2390d5ae2c17e4db691d5df7a7b60fc071ae509c5414be7d5da74df2811e83d3668c4a0b1abc84b9fa7d96b4cdf30bba68517ad2a93e233b042972ac0553a4801c9ebe07bf57ebe9a3b3d6d663965260e50f3b8f46db0531761e60340a2bddc3426098397fda54044a17e5244549f9869b460ca5e6e216b6f6a2db0580b480ca2afe6ec6b46eedacfa4aa45038809ece0c5978653d6c85f678e7f5a2156d1bedd8117751e64a4b0dcd140f3040b021821a8d93aed8d01ba36db6c82372211fed714d9a32607038cdfd565bd529ffc637212aaa2c224ef22b603eccefb5bf1e085c191d4b24fe742b17ab3f55d4e6f05ef - - F-Droid - - - https://f-droid.org/repo - http://fdroidorg6cooksyluodepej4erfctzk7rrjpjbbr6wx24jh3lqyfwyd.onion/fdroid/repo - http://dotsrccccbidkzg7oc7oj4ugxrlfbt64qebyunxbrgqhxiwj3nl6vcad.onion/fdroid/repo - http://ftpfaudev4triw2vxiwzf4334e3mynz7osqgtozhbc77fixncqzbyoyd.onion/fdroid/repo - http://lysator7eknrfl47rlyxvgeamrv7ucefgrrlhk7rouv3sna25asetwid.onion/pub/fdroid/repo - http://mirror.ossplanetnyou5xifr6liw5vhzwc2g2fmmlohza25wwgnnaw65ytfsad.onion/fdroid/repo - https://fdroid.tetaneutral.net/fdroid/repo - https://ftp.agdsn.de/fdroid/repo - https://ftp.fau.de/fdroid/repo - https://ftp.gwdg.de/pub/android/fdroid/repo - https://ftp.lysator.liu.se/pub/fdroid/repo - https://mirror.cyberbits.eu/fdroid/repo - https://mirror.fcix.net/fdroid/repo - https://mirror.kumi.systems/fdroid/repo - https://mirror.level66.network/fdroid/repo - https://mirror.ossplanet.net/fdroid/repo - https://mirrors.dotsrc.org/fdroid/repo - https://opencolo.mm.fcix.net/fdroid/repo - https://plug-mirror.rcac.purdue.edu/fdroid/repo - - - The official F-Droid Free Software repository. Everything in this repository is always built from the source code. - - - 13 - - 1 - - ignore - - - 3082035e30820246a00302010202044c49cd00300d06092a864886f70d01010505003071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b73301e170d3130303732333137313032345a170d3337313230383137313032345a3071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b7330820122300d06092a864886f70d01010105000382010f003082010a028201010096d075e47c014e7822c89fd67f795d23203e2a8843f53ba4e6b1bf5f2fd0e225938267cfcae7fbf4fe596346afbaf4070fdb91f66fbcdf2348a3d92430502824f80517b156fab00809bdc8e631bfa9afd42d9045ab5fd6d28d9e140afc1300917b19b7c6c4df4a494cf1f7cb4a63c80d734265d735af9e4f09455f427aa65a53563f87b336ca2c19d244fcbba617ba0b19e56ed34afe0b253ab91e2fdb1271f1b9e3c3232027ed8862a112f0706e234cf236914b939bcf959821ecb2a6c18057e070de3428046d94b175e1d89bd795e535499a091f5bc65a79d539a8d43891ec504058acb28c08393b5718b57600a211e803f4a634e5c57f25b9b8c4422c6fd90203010001300d06092a864886f70d0101050500038201010008e4ef699e9807677ff56753da73efb2390d5ae2c17e4db691d5df7a7b60fc071ae509c5414be7d5da74df2811e83d3668c4a0b1abc84b9fa7d96b4cdf30bba68517ad2a93e233b042972ac0553a4801c9ebe07bf57ebe9a3b3d6d663965260e50f3b8f46db0531761e60340a2bddc3426098397fda54044a17e5244549f9869b460ca5e6e216b6f6a2db0580b480ca2afe6ec6b46eedacfa4aa45038809ece0c5978653d6c85f678e7f5a2156d1bedd8117751e64a4b0dcd140f3040b021821a8d93aed8d01ba36db6c82372211fed714d9a32607038cdfd565bd529ffc637212aaa2c224ef22b603eccefb5bf1e085c191d4b24fe742b17ab3f55d4e6f05ef - - From 108105596ddae91fbd3d5c6422206e0d742db6b2 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 3 Nov 2023 17:44:24 -0300 Subject: [PATCH 12/33] [db] allow re-ordering repo list and thus priorities --- .../org/fdroid/database/RepositoryDaoTest.kt | 104 ++++++++++++++++++ .../java/org/fdroid/database/Repository.kt | 7 ++ .../java/org/fdroid/database/RepositoryDao.kt | 76 ++++++++++++- .../main/java/org/fdroid/index/RepoManager.kt | 27 ++++- 4 files changed, 208 insertions(+), 6 deletions(-) diff --git a/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt index 1d2c6ac4c..2084cc8a3 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt @@ -12,6 +12,7 @@ import org.junit.Test import org.junit.runner.RunWith import kotlin.random.Random import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -287,4 +288,107 @@ internal class RepositoryDaoTest : DbTest() { repoDao.insertOrReplace(getRandomRepo()) assertEquals(Int.MAX_VALUE - 4, repoDao.getMinRepositoryWeight()) } + + @Test + fun testReorderRepositories() { + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId4 = repoDao.insertOrReplace(getRandomRepo()) + val repoId5 = repoDao.insertOrReplace(getRandomRepo()) + + // repos are listed in the order they entered the DB [1, 2, 3, 4, 5] + assertEquals( + listOf(repoId1, repoId2, repoId3, repoId4, repoId5), + repoDao.getRepositories().map { it.repoId }, + ) + + // 2 gets moved to 5 [1, 3, 4, 5, 2] + repoDao.reorderRepositories( + repoToReorder = repoDao.getRepository(repoId2) ?: fail(), + repoTarget = repoDao.getRepository(repoId5) ?: fail(), + ) + assertEquals( + listOf(repoId1, repoId3, repoId4, repoId5, repoId2), + repoDao.getRepositories().map { it.repoId }, + ) + + // 5 gets moved to 1 [5, 1, 3, 4, 2] + repoDao.reorderRepositories( + repoToReorder = repoDao.getRepository(repoId5) ?: fail(), + repoTarget = repoDao.getRepository(repoId1) ?: fail(), + ) + assertEquals( + listOf(repoId5, repoId1, repoId3, repoId4, repoId2), + repoDao.getRepositories().map { it.repoId }, + ) + + // 3 gets moved to 5 [3, 5, 1, 4, 2] + repoDao.reorderRepositories( + repoToReorder = repoDao.getRepository(repoId3) ?: fail(), + repoTarget = repoDao.getRepository(repoId5) ?: fail(), + ) + assertEquals( + listOf(repoId3, repoId5, repoId1, repoId4, repoId2), + repoDao.getRepositories().map { it.repoId }, + ) + + // 3 gets moved to itself, list shouldn't change [3, 5, 1, 4, 2] + repoDao.reorderRepositories( + repoToReorder = repoDao.getRepository(repoId3) ?: fail(), + repoTarget = repoDao.getRepository(repoId3) ?: fail(), + ) + assertEquals( + listOf(repoId3, repoId5, repoId1, repoId4, repoId2), + repoDao.getRepositories().map { it.repoId }, + ) + + // we'll add an archive repo for repo1 to the list [3, 5, (1, 1a), 4, 2] + repoDao.updateRepository(repoId1, "1234abcd") + val repo1 = repoDao.getRepository(repoId1) ?: fail() + val repo1a = InitialRepository( + name = getRandomString(), + address = "https://example.org/archive", + description = getRandomString(), + certificate = repo1.certificate ?: fail(), + version = 42L, + enabled = false, + ) + val repoId1a = repoDao.insert(repo1a) + repoDao.setWeight(repoId1a, repo1.weight - 1) + + // now we move repo 1 to position of repo 2 [3, 5, 4, 2, (1, 1a)] + repoDao.reorderRepositories( + repoToReorder = repoDao.getRepository(repoId1) ?: fail(), + repoTarget = repoDao.getRepository(repoId2) ?: fail(), + ) + assertEquals( + listOf(repoId3, repoId5, repoId4, repoId2, repoId1, repoId1a), + repoDao.getRepositories().map { it.repoId }, + ) + + // now move repo 1 and its archive to top position [(1, 1a), 3, 5, 4, 2] + repoDao.reorderRepositories( + repoToReorder = repoDao.getRepository(repoId1) ?: fail(), + repoTarget = repoDao.getRepository(repoId3) ?: fail(), + ) + assertEquals( + listOf(repoId1, repoId1a, repoId3, repoId5, repoId4, repoId2), + repoDao.getRepositories().map { it.repoId }, + ) + + // archive repos can't be reordered directly + assertFailsWith { + repoDao.reorderRepositories( + repoToReorder = repoDao.getRepository(repoId1a) ?: fail(), + repoTarget = repoDao.getRepository(repoId3) ?: fail(), + ) + } + assertFailsWith { + repoDao.reorderRepositories( + repoToReorder = repoDao.getRepository(repoId3) ?: fail(), + repoTarget = repoDao.getRepository(repoId1a) ?: fail(), + ) + } + } } diff --git a/libs/database/src/main/java/org/fdroid/database/Repository.kt b/libs/database/src/main/java/org/fdroid/database/Repository.kt index 19b173cba..c5135bfa7 100644 --- a/libs/database/src/main/java/org/fdroid/database/Repository.kt +++ b/libs/database/src/main/java/org/fdroid/database/Repository.kt @@ -137,6 +137,13 @@ public data class Repository internal constructor( public val formatVersion: IndexFormatVersion? get() = repository.formatVersion public val certificate: String? get() = repository.certificate + /** + * True if this repository is an archive repo. + * It is suggested to not show archive repos in the list of repos in the UI. + */ + public val isArchiveRepo: Boolean + get() = repository.address.trimEnd('/').endsWith("/archive") + public fun getName(localeList: LocaleListCompat): String? = repository.name.getBestLocale(localeList) diff --git a/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt index 929d4f344..6c8347410 100644 --- a/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -212,18 +212,31 @@ internal interface RepositoryDaoInt : RepositoryDao { @Query("SELECT * FROM ${CoreRepository.TABLE} WHERE repoId = :repoId") override fun getRepository(repoId: Long): Repository? - // the query uses strange ordering as a hacky workaround to not return default archive repos + /** + * Returns a non-archive repository with the given [certificate], if it exists in the DB. + */ @Transaction - @Query("""SELECT * FROM ${CoreRepository.TABLE} WHERE certificate = :certificate - COLLATE NOCASE ORDER BY repoId DESC LIMIT 1""") + @Query("""SELECT * FROM ${CoreRepository.TABLE} + WHERE certificate = :certificate AND address NOT LIKE "%/archive" COLLATE NOCASE + LIMIT 1""") fun getRepository(certificate: String): Repository? @Transaction - @Query("SELECT * FROM ${CoreRepository.TABLE}") + @RewriteQueriesToDropUnusedColumns + @Query( + """SELECT * FROM ${CoreRepository.TABLE} + JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) + ORDER BY pref.weight DESC""" + ) override fun getRepositories(): List @Transaction - @Query("SELECT * FROM ${CoreRepository.TABLE}") + @RewriteQueriesToDropUnusedColumns + @Query( + """SELECT * FROM ${CoreRepository.TABLE} + JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) + ORDER BY pref.weight DESC""" + ) override fun getLiveRepositories(): LiveData> @Query("SELECT * FROM ${RepositoryPreferences.TABLE} WHERE repoId = :repoId") @@ -356,6 +369,59 @@ internal interface RepositoryDaoInt : RepositoryDao { WHERE repoId = :repoId""") override fun updateDisabledMirrors(repoId: Long, disabledMirrors: List) + /** + * Changes repository weights/priorities that determine list order and preferred repositories. + * The lower a repository is in the list, the lower is its priority. + * If an app is in more than one repo, by default, the repo higher in the list wins. + * + * @param repoToReorder this repository will change its position in the list. + * @param repoTarget the repository in which place the [repoToReorder] shall be moved. + * If our list is [ A B C D ] and we call reorderRepositories(B, D), + * then the new list will be [ A C D B ]. + * + * @throws IllegalArgumentException if one of the repos is an archive repo. + * Those are expected to be tied to their main repo one down the list + * and are moved automatically when their main repo moves. + */ + @Transaction + fun reorderRepositories(repoToReorder: Repository, repoTarget: Repository) { + require(!repoToReorder.isArchiveRepo && !repoTarget.isArchiveRepo) { + "Re-ordering of archive repos is not supported" + } + if (repoToReorder.weight > repoTarget.weight) { + // repoToReorder is higher, + // so move repos below repoToReorder (and its archive below) two weights up + shiftRepoWeights(repoTarget.weight, repoToReorder.weight - 2, 2) + } else if (repoToReorder.weight < repoTarget.weight) { + // repoToReorder is lower, so move repos above repoToReorder two weights down + shiftRepoWeights(repoToReorder.weight + 1, repoTarget.weight, -2) + } else { + return // both repos have same weight, not re-ordering anything + } + // move repoToReorder in place of repoTarget + setWeight(repoToReorder.repoId, repoTarget.weight) + // also adjust weight of archive repo, if it exists + val archiveRepoId = repoToReorder.certificate?.let { getArchiveRepoId(it) } + if (archiveRepoId != null) { + setWeight(archiveRepoId, repoTarget.weight - 1) + } + } + + @Query("""UPDATE ${RepositoryPreferences.TABLE} SET weight = :weight WHERE repoId = :repoId""") + fun setWeight(repoId: Long, weight: Int) + + @Query( + """UPDATE ${RepositoryPreferences.TABLE} SET weight = weight + :offset + WHERE weight >= :weightFrom AND weight <= :weightTo""" + ) + fun shiftRepoWeights(weightFrom: Int, weightTo: Int, offset: Int) + + @Query( + """SELECT repoId FROM ${CoreRepository.TABLE} + WHERE certificate = :cert AND address LIKE '%/archive' COLLATE NOCASE""" + ) + fun getArchiveRepoId(cert: String): Long? + @Transaction override fun deleteRepository(repoId: Long) { deleteCoreRepository(repoId) diff --git a/libs/database/src/main/java/org/fdroid/index/RepoManager.kt b/libs/database/src/main/java/org/fdroid/index/RepoManager.kt index 5cd554525..3cc09b5df 100644 --- a/libs/database/src/main/java/org/fdroid/index/RepoManager.kt +++ b/libs/database/src/main/java/org/fdroid/index/RepoManager.kt @@ -15,8 +15,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.fdroid.database.AppPrefs import org.fdroid.database.FDroidDatabase import org.fdroid.database.Repository +import org.fdroid.database.RepositoryDaoInt import org.fdroid.download.DownloaderFactory import org.fdroid.download.HttpManager import org.fdroid.repo.AddRepoState @@ -37,7 +39,7 @@ public class RepoManager @JvmOverloads constructor( private val coroutineContext: CoroutineContext = Dispatchers.IO, ) { - private val repositoryDao = db.getRepositoryDao() + private val repositoryDao = db.getRepositoryDao() as RepositoryDaoInt private val tempFileProvider = TempFileProvider { File.createTempFile("dl-", "", context.cacheDir) } @@ -160,6 +162,29 @@ public class RepoManager @JvmOverloads constructor( repoAdder.abortAddingRepo() } + /** + * Changes repository priorities that determine the order + * they are returned from [getRepositories] and the preferred repositories. + * The lower a repository is in the list, the lower is its priority. + * If an app is in more than one repository, by default, + * the repo higher in the list will provide metadata and updates. + * Only setting [AppPrefs.preferredRepoId] overrides this. + * + * @param repoToReorder this repository will change its position in the list. + * @param repoTarget the repository in which place the [repoToReorder] shall be moved. + * If our list is [ A B C D ] and we call reorderRepositories(B, D), + * then the new list will be [ A C D B ]. + * @throws IllegalArgumentException if one of the repos is an archive repo. + * Those are expected to be tied to their main repo one down the list + * and are moved automatically when their main repo moves. + */ + @AnyThread + public fun reorderRepositories(repoToReorder: Repository, repoTarget: Repository) { + GlobalScope.launch(coroutineContext) { + repositoryDao.reorderRepositories(repoToReorder, repoTarget) + } + } + /** * Returns true if the given [uri] belongs to a swap repo. */ From 0b292f338361492f704e3f61e4d755db8a4cd235 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 6 Nov 2023 17:18:20 -0300 Subject: [PATCH 13/33] [db] Add convenience method for setting preferred repo via RepoManager --- .../src/main/java/org/fdroid/index/RepoManager.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/libs/database/src/main/java/org/fdroid/index/RepoManager.kt b/libs/database/src/main/java/org/fdroid/index/RepoManager.kt index 3cc09b5df..edea118b2 100644 --- a/libs/database/src/main/java/org/fdroid/index/RepoManager.kt +++ b/libs/database/src/main/java/org/fdroid/index/RepoManager.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.fdroid.database.AppPrefs +import org.fdroid.database.AppPrefsDaoInt import org.fdroid.database.FDroidDatabase import org.fdroid.database.Repository import org.fdroid.database.RepositoryDaoInt @@ -32,7 +33,7 @@ import kotlin.coroutines.CoroutineContext @OptIn(DelicateCoroutinesApi::class) public class RepoManager @JvmOverloads constructor( context: Context, - db: FDroidDatabase, + private val db: FDroidDatabase, downloaderFactory: DownloaderFactory, httpManager: HttpManager, private val repoUriBuilder: RepoUriBuilder = defaultRepoUriBuilder, @@ -40,6 +41,7 @@ public class RepoManager @JvmOverloads constructor( ) { private val repositoryDao = db.getRepositoryDao() as RepositoryDaoInt + private val appPrefsDao = db.getAppPrefsDao() as AppPrefsDaoInt private val tempFileProvider = TempFileProvider { File.createTempFile("dl-", "", context.cacheDir) } @@ -162,6 +164,16 @@ public class RepoManager @JvmOverloads constructor( repoAdder.abortAddingRepo() } + @AnyThread + public fun setPreferredRepoId(packageName: String, repoId: Long) { + GlobalScope.launch(coroutineContext) { + db.runInTransaction { + val appPrefs = appPrefsDao.getAppPrefsOrNull(packageName) ?: AppPrefs(packageName) + appPrefsDao.update(appPrefs.copy(preferredRepoId = repoId)) + } + } + } + /** * Changes repository priorities that determine the order * they are returned from [getRepositories] and the preferred repositories. From 25326e24f6a1f387cb172a2aa94068221f57e426 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 3 Nov 2023 14:31:56 -0300 Subject: [PATCH 14/33] [app] Allow repo position/priority to be reordered by long tap + drag --- .../views/repos/ManageReposActivity.java | 63 ++++++++++++++++++- .../fdroid/views/repos/RepoAdapter.java | 6 ++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java b/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java index 1693a2d7b..52105aace 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java @@ -24,11 +24,15 @@ import android.content.Intent; import android.os.Build; import android.os.Bundle; import android.os.UserManager; +import android.util.Log; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.NavUtils; import androidx.core.app.TaskStackBuilder; +import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.appbar.MaterialToolbar; @@ -46,10 +50,59 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable; public class ManageReposActivity extends AppCompatActivity implements RepoAdapter.RepoItemListener { - public static final String EXTRA_FINISH_AFTER_ADDING_REPO = "finishAfterAddingRepo"; private RepoManager repoManager; private final CompositeDisposable compositeDisposable = new CompositeDisposable(); + private final RepoAdapter repoAdapter = new RepoAdapter(this); + private boolean isItemReorderingEnabled = false; + private final ItemTouchHelper.Callback itemTouchCallback = + new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0) { + + private int lastFromPos = -1; + private int lastToPos = -1; + + @Override + public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, + @NonNull RecyclerView.ViewHolder target) { + final int fromPos = viewHolder.getBindingAdapterPosition(); + final int toPos = target.getBindingAdapterPosition(); + repoAdapter.notifyItemMoved(fromPos, toPos); + if (lastFromPos == -1) lastFromPos = fromPos; + lastToPos = toPos; + return true; + } + + @Override + public void onSelectedChanged(@Nullable RecyclerView.ViewHolder viewHolder, int actionState) { + super.onSelectedChanged(viewHolder, actionState); + if (actionState == ItemTouchHelper.ACTION_STATE_IDLE) { + if (lastFromPos != lastToPos) { + Repository repoToMove = repoAdapter.getItem(lastFromPos); + Repository repoDropped = repoAdapter.getItem(lastToPos); + if (repoToMove != null && repoDropped != null) { + // don't allow more re-orderings until this one was completed + isItemReorderingEnabled = false; + repoManager.reorderRepositories(repoToMove, repoDropped); + } else { + Log.w("ManageReposActivity", + "Could not find one of the repos: " + lastFromPos + " to " + lastToPos); + } + } + lastFromPos = -1; + lastToPos = -1; + } + } + + @Override + public boolean isLongPressDragEnabled() { + return isItemReorderingEnabled; + } + + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { + // noop + } + }; @Override protected void onCreate(Bundle savedInstanceState) { @@ -81,9 +134,13 @@ public class ManageReposActivity extends AppCompatActivity implements RepoAdapte }); final RecyclerView repoList = findViewById(R.id.list); - RepoAdapter repoAdapter = new RepoAdapter(this); + final ItemTouchHelper touchHelper = new ItemTouchHelper(itemTouchCallback); + touchHelper.attachToRecyclerView(repoList); repoList.setAdapter(repoAdapter); - FDroidApp.getRepoManager(this).getLiveRepositories().observe(this, repoAdapter::updateItems); + FDroidApp.getRepoManager(this).getLiveRepositories().observe(this, items -> { + repoAdapter.updateItems(items); + isItemReorderingEnabled = true; + }); } @Override diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoAdapter.java index 46db4117d..63899391d 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoAdapter.java @@ -9,6 +9,7 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.core.os.LocaleListCompat; import androidx.recyclerview.widget.RecyclerView; @@ -38,6 +39,11 @@ public class RepoAdapter extends RecyclerView.Adapter items) { From f6970e4245e7bee5ddbf22e33554ebb10fb82e8d Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 6 Nov 2023 17:20:49 -0300 Subject: [PATCH 15/33] [app] allow changing preferred repo in app details --- .../org/fdroid/fdroid/compose/ComposeUtils.kt | 10 +- .../fdroid/views/AppDetailsActivity.java | 26 ++ .../views/AppDetailsRecyclerViewAdapter.java | 45 +-- .../fdroid/views/appdetails/RepoChooser.kt | 275 ++++++++++++++++++ .../main/res/layout/app_details2_header.xml | 37 +-- app/src/main/res/values/strings.xml | 16 +- .../fdroid/views/AppDetailsAdapterTest.java | 10 + 7 files changed, 370 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt diff --git a/app/src/main/java/org/fdroid/fdroid/compose/ComposeUtils.kt b/app/src/main/java/org/fdroid/fdroid/compose/ComposeUtils.kt index c8fc725f6..f287d7b41 100644 --- a/app/src/main/java/org/fdroid/fdroid/compose/ComposeUtils.kt +++ b/app/src/main/java/org/fdroid/fdroid/compose/ComposeUtils.kt @@ -45,8 +45,12 @@ object ComposeUtils { ) val newColors = (colors ?: MaterialTheme.colors).let { c -> if (!LocalInspectionMode.current && !c.isLight && Preferences.get().isPureBlack) { - c.copy(background = Color.Black) - } else c + c.copy(background = Color.Black, surface = Color(0xff1e1e1e)) + } else if (!c.isLight) { + c.copy(surface = Color(0xff1e1e1e)) + } else { + c + } } MaterialTheme( colors = newColors, @@ -111,7 +115,7 @@ object ComposeUtils { ) Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) } - Text(text = text.uppercase(Locale.getDefault())) + Text(text = text.uppercase(Locale.getDefault()), maxLines = 1) } } diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java index 4b0b4ddc9..dc5c20d3c 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java @@ -72,6 +72,7 @@ import org.fdroid.fdroid.installer.InstallerFactory; import org.fdroid.fdroid.installer.InstallerService; import org.fdroid.fdroid.nearby.PublicSourceDirProvider; import org.fdroid.fdroid.views.apps.FeatureImage; +import org.fdroid.index.RepoManager; import java.util.ArrayList; import java.util.Iterator; @@ -710,6 +711,7 @@ public class AppDetailsActivity extends AppCompatActivity private void onAppPrefsChanged(AppPrefs appPrefs) { this.appPrefs = appPrefs; + loadRepos(appPrefs.getPreferredRepoId()); if (app != null) updateAppInfo(app, versions, appPrefs); } @@ -724,6 +726,20 @@ public class AppDetailsActivity extends AppCompatActivity supportInvalidateOptionsMenu(); } + private void loadRepos(@Nullable Long preferredRepoId) { + Utils.runOffUiThread(() -> db.getAppDao().getRepositoryIdsForApp(packageName), repoIds -> { + List repoList = new ArrayList<>(repoIds.size()); + RepoManager repoManager = FDroidApp.getRepoManager(this); + if (repoManager.getRepositories().size() <= 2) return; // don't show if only official repo+archive added + for (long repoId: repoIds) { + Repository repo = repoManager.getRepository(repoId); + if (repo != null) repoList.add(repo); + } + long prefId = preferredRepoId == null ? app.repoId : preferredRepoId; + adapter.setRepos(repoList, prefId); + }); + } + @Nullable @SuppressLint("PackageManagerGetSignatures") private PackageInfo getPackageInfo(String packageName) { @@ -788,6 +804,16 @@ public class AppDetailsActivity extends AppCompatActivity } } + @Override + public void onRepoChanged(long repoId) { + Utils.runOffUiThread(() -> db.getAppDao().getApp(repoId, app.packageName), this::onAppChanged); + } + + @Override + public void onPreferredRepoChanged(long repoId) { + FDroidApp.getRepoManager(this).setPreferredRepoId(app.packageName, repoId); + } + /** * Uninstall the app from the current screen. Since there are many ways * to uninstall an app, including from Google Play, {@code adb uninstall}, diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java index 0fa42bd37..5e9dec686 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java @@ -34,6 +34,8 @@ import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; +import androidx.compose.ui.platform.ComposeView; +import androidx.compose.ui.platform.ViewCompositionStrategy; import androidx.core.content.ContextCompat; import androidx.core.content.FileProvider; import androidx.core.graphics.drawable.DrawableCompat; @@ -49,7 +51,6 @@ import androidx.recyclerview.widget.LinearSmoothScroller; import androidx.recyclerview.widget.RecyclerView; import androidx.transition.TransitionManager; -import com.bumptech.glide.Glide; import com.google.android.material.progressindicator.LinearProgressIndicator; import org.apache.commons.io.FilenameUtils; @@ -66,6 +67,7 @@ import org.fdroid.fdroid.installer.SessionInstallManager; import org.fdroid.fdroid.privileged.views.AppDiff; import org.fdroid.fdroid.privileged.views.AppSecurityPermissions; import org.fdroid.fdroid.views.appdetails.AntiFeaturesListingView; +import org.fdroid.fdroid.views.appdetails.RepoChooserKt; import org.fdroid.fdroid.views.main.MainActivity; import org.fdroid.index.v2.FileV2; @@ -98,6 +100,10 @@ public class AppDetailsRecyclerViewAdapter void installCancel(); void launchApk(); + + void onRepoChanged(long repoId); + + void onPreferredRepoChanged(long repoId); } private static final int VIEWTYPE_HEADER = 0; @@ -115,6 +121,9 @@ public class AppDetailsRecyclerViewAdapter private final AppDetailsRecyclerViewAdapterCallbacks callbacks; private RecyclerView recyclerView; private final List items = new ArrayList<>(); + private final List repos = new ArrayList<>(); + @Nullable + private Long preferredRepoId = null; private final List versions = new ArrayList<>(); private final List compatibleVersionsDifferentSigner = new ArrayList<>(); private boolean showVersions; @@ -177,6 +186,13 @@ public class AppDetailsRecyclerViewAdapter notifyDataSetChanged(); } + void setRepos(List repos, long preferredRepoId) { + this.repos.clear(); + this.repos.addAll(repos); + this.preferredRepoId = preferredRepoId; + notifyItemChanged(0); // header changed + } + private void addInstalledApkIfExists(final List apks) { if (app == null) return; Apk installedApk = app.getInstalledApk(context, apks); @@ -378,8 +394,7 @@ public class AppDetailsRecyclerViewAdapter final TextView titleView; final TextView authorView; final TextView lastUpdateView; - final ImageView repoLogoView; - final TextView repoNameView; + final ComposeView repoChooserView; final TextView warningView; final TextView summaryView; final TextView whatsNewView; @@ -404,8 +419,9 @@ public class AppDetailsRecyclerViewAdapter titleView = view.findViewById(R.id.title); authorView = view.findViewById(R.id.author); lastUpdateView = view.findViewById(R.id.text_last_update); - repoLogoView = view.findViewById(R.id.repo_icon); - repoNameView = view.findViewById(R.id.repo_name); + repoChooserView = view.findViewById(R.id.repoChooserView); + repoChooserView.setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed.INSTANCE); warningView = view.findViewById(R.id.warning); summaryView = view.findViewById(R.id.summary); whatsNewView = view.findViewById(R.id.latest); @@ -500,18 +516,6 @@ public class AppDetailsRecyclerViewAdapter if (app == null) return; Utils.setIconFromRepoOrPM(app, iconView, iconView.getContext()); titleView.setText(app.name); - Repository repo = FDroidApp.getRepoManager(context).getRepository(app.repoId); - if (repo != null && !repo.getAddress().equals("https://f-droid.org/repo")) { - LocaleListCompat locales = LocaleListCompat.getDefault(); - Utils.loadWithGlide(context, repo.getRepoId(), repo.getIcon(locales), repoLogoView); - repoNameView.setText(repo.getName(locales)); - repoLogoView.setVisibility(View.VISIBLE); - repoNameView.setVisibility(View.VISIBLE); - } else { - Glide.with(context).clear(repoLogoView); - repoLogoView.setVisibility(View.GONE); - repoNameView.setVisibility(View.GONE); - } if (!TextUtils.isEmpty(app.authorName)) { authorView.setText(context.getString(R.string.by_author_format, app.authorName)); authorView.setVisibility(View.VISIBLE); @@ -534,6 +538,13 @@ public class AppDetailsRecyclerViewAdapter } else { lastUpdateView.setVisibility(View.GONE); } + if (app != null && preferredRepoId != null) { + RepoChooserKt.setContentRepoChooser(repoChooserView, repos, app.repoId, preferredRepoId, + repo -> callbacks.onRepoChanged(repo.getRepoId()), callbacks::onPreferredRepoChanged); + repoChooserView.setVisibility(View.VISIBLE); + } else { + repoChooserView.setVisibility(View.GONE); + } if (SessionInstallManager.canBeUsed(context) && suggestedApk != null && !SessionInstallManager.isTargetSdkSupported(suggestedApk.targetSdkVersion)) { diff --git a/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt b/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt new file mode 100644 index 000000000..b95498396 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt @@ -0,0 +1,275 @@ +package org.fdroid.fdroid.views.appdetails + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.ContentAlpha +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Star +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Alignment.Companion.End +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.content.res.ResourcesCompat +import androidx.core.os.LocaleListCompat +import androidx.core.util.Consumer +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import org.fdroid.database.Repository +import org.fdroid.fdroid.R +import org.fdroid.fdroid.Utils +import org.fdroid.fdroid.compose.ComposeUtils.FDroidContent +import org.fdroid.fdroid.compose.ComposeUtils.FDroidOutlineButton +import org.fdroid.index.IndexFormatVersion.TWO + +/** + * A helper method to show [RepoChooser] from Java code. + */ +fun setContentRepoChooser( + composeView: ComposeView, + repos: List, + currentRepoId: Long, + preferredRepoId: Long, + onRepoChanged: Consumer, + onPreferredRepoChanged: Consumer, +) { + composeView.setContent { + FDroidContent { + RepoChooser( + repos = repos, + currentRepoId = currentRepoId, + preferredRepoId = preferredRepoId, + onRepoChanged = onRepoChanged::accept, + onPreferredRepoChanged = onPreferredRepoChanged::accept, + modifier = Modifier.background(MaterialTheme.colors.surface), + ) + } + } +} + +@Composable +fun RepoChooser( + repos: List, + currentRepoId: Long, + preferredRepoId: Long, + onRepoChanged: (Repository) -> Unit, + onPreferredRepoChanged: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + if (repos.isEmpty()) { + // no-op should not happen + } else if (repos.size == 1) { + RepoItem( + repo = repos[0], + isPreferred = false, // don't show "preferred" if the only repo anyway + modifier = Modifier.fillMaxWidth(), + ) + } else { + RepoDropDown( + repos = repos, + currentRepoId = currentRepoId, + preferredRepoId = preferredRepoId, + onRepoChanged = onRepoChanged, + onPreferredRepoChanged = onPreferredRepoChanged, + modifier = modifier, + ) + } +} + +@Composable +@OptIn(ExperimentalGlideComposeApi::class) +private fun RepoDropDown( + repos: List, + currentRepoId: Long, + preferredRepoId: Long, + onRepoChanged: (Repository) -> Unit, + onPreferredRepoChanged: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + val currentRepo = repos.find { it.repoId == currentRepoId } + ?: error("Current repoId not in list") + val localeList = LocaleListCompat.getDefault() + val res = LocalContext.current.resources + + Column( + modifier = modifier.fillMaxWidth(), + ) { + Box { + OutlinedTextField( + value = TextFieldValue(buildAnnotatedString { + append(currentRepo.getName(localeList) ?: "Unknown Repository") + if (currentRepo.repoId == preferredRepoId) { + append(" ") + pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) + append("★ ") + append(stringResource(R.string.app_details_repository_preferred)) + } + }), + textStyle = MaterialTheme.typography.body2, + onValueChange = {}, + label = { + Text(stringResource(R.string.app_details_repositories)) + }, + leadingIcon = { + if (LocalInspectionMode.current) Image( + painter = rememberDrawablePainter( + ResourcesCompat.getDrawable(res, R.drawable.ic_launcher, null) + ), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) else GlideImage( + model = Utils.getDownloadRequest( + currentRepo, + currentRepo.getIcon(localeList) + ), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) { + it.fallback(R.drawable.ic_repo_app_default) + .error(R.drawable.ic_repo_app_default) + } + }, + trailingIcon = { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = stringResource(R.string.app_details_repository_expand), + ) + }, + singleLine = true, + enabled = false, + colors = TextFieldDefaults.outlinedTextFieldColors( // hack to enable clickable + disabledTextColor = LocalContentColor.current.copy(LocalContentAlpha.current), + disabledBorderColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled), + disabledLabelColor = MaterialTheme.colors.onSurface.copy(ContentAlpha.medium), + disabledLeadingIconColor = MaterialTheme.colors.onSurface, + ), + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = { expanded = true }), + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + repos.iterator().forEach { repo -> + DropdownMenuItem(onClick = { + onRepoChanged(repo) + expanded = false + }) { + RepoItem(repo, repo.repoId == preferredRepoId) + } + } + } + } + if (currentRepo.repoId != preferredRepoId) { + FDroidOutlineButton( + text = stringResource(R.string.app_details_repository_button_prefer), + imageVector = Icons.Default.Star, + onClick = { onPreferredRepoChanged(currentRepo.repoId) }, + modifier = Modifier.align(End), + ) + } + } +} + +@Composable +@OptIn(ExperimentalGlideComposeApi::class) +private fun RepoItem(repo: Repository, isPreferred: Boolean, modifier: Modifier = Modifier) { + Row( + horizontalArrangement = spacedBy(8.dp), + verticalAlignment = CenterVertically, + modifier = modifier, + ) { + val localeList = LocaleListCompat.getDefault() + val res = LocalContext.current.resources + if (LocalInspectionMode.current) Image( + painter = rememberDrawablePainter( + ResourcesCompat.getDrawable(res, R.drawable.ic_launcher, null) + ), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) else GlideImage( + model = Utils.getDownloadRequest(repo, repo.getIcon(localeList)), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) { + it.fallback(R.drawable.ic_repo_app_default).error(R.drawable.ic_repo_app_default) + } + Text( + text = buildAnnotatedString { + append(repo.getName(localeList) ?: "Unknown Repository") + if (isPreferred) { + append(" ") + pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) + append("★ ") + append(stringResource(R.string.app_details_repository_preferred)) + } + }, + style = MaterialTheme.typography.body2, + ) + } +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +fun RepoChooserSingleRepoPreview() { + val repo1 = Repository(1L, "1", 1L, TWO, null, 1L, 1, 1L) + FDroidContent { + RepoChooser(listOf(repo1), 1L, 1L, {}, {}) + } +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +fun RepoChooserPreview() { + val repo1 = Repository(1L, "1", 1L, TWO, null, 1L, 1, 1L) + val repo2 = Repository(2L, "2", 2L, TWO, null, 2L, 2, 2L) + val repo3 = Repository(3L, "2", 3L, TWO, null, 3L, 3, 3L) + FDroidContent { + RepoChooser(listOf(repo1, repo2, repo3), 1L, 1L, {}, {}) + } +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +fun RepoChooserNightPreview() { + val repo1 = Repository(1L, "1", 1L, TWO, null, 1L, 1, 1L) + val repo2 = Repository(2L, "2", 2L, TWO, null, 2L, 2, 2L) + val repo3 = Repository(3L, "2", 3L, TWO, null, 3L, 3, 3L) + FDroidContent { + RepoChooser(listOf(repo1, repo2, repo3), 1L, 2L, {}, {}) + } +} diff --git a/app/src/main/res/layout/app_details2_header.xml b/app/src/main/res/layout/app_details2_header.xml index 4c570c987..27f0a09d9 100644 --- a/app/src/main/res/layout/app_details2_header.xml +++ b/app/src/main/res/layout/app_details2_header.xml @@ -88,32 +88,13 @@ app:barrierDirection="bottom" app:constraint_referenced_ids="icon,text_last_update" /> - - - + android:layout_marginTop="8dp" + app:layout_constraintTop_toBottomOf="@id/barrier" + tools:composableName="org.fdroid.fdroid.views.appdetails.RepoChooserKt.RepoChooserPreview" /> @@ -140,7 +121,7 @@ android:ellipsize="marquee" android:visibility="gone" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@+id/repo_icon" + app:layout_constraintTop_toBottomOf="@+id/repoChooserView" tools:text="Open" tools:visibility="visible" /> @@ -152,7 +133,7 @@ android:layout_marginTop="4dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/repo_icon" + app:layout_constraintTop_toBottomOf="@+id/repoChooserView" tools:visibility="visible"> App Details No such app found. + + Repository + + Repositories + + (preferred) + + Prefer Repository + Expand repository list Buy the developers of %1$s a coffee! %1$s is created by %2$s. Buy them a coffee! @@ -215,7 +230,6 @@ This often occurs with apps installed via Google Play or other sources, if they Your device admin doesn\'t allow installing apps from unknown sources, that includes new repos Unknown sources can\'t be added by this user, that includes new repos - Repository: %s Repositories Add additional sources of apps diff --git a/app/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java b/app/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java index b99a725a3..8c657074b 100644 --- a/app/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java +++ b/app/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java @@ -147,6 +147,16 @@ public class AppDetailsAdapterTest { public void launchApk() { } + + @Override + public void onRepoChanged(long repoId) { + + } + + @Override + public void onPreferredRepoChanged(long repoId) { + + } }; } From bb8cb29bfe88c09edd66bb7da112a0a846ea3ee2 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 8 Nov 2023 10:30:08 -0300 Subject: [PATCH 16/33] [app] refactor RepoIcon into its own Composable --- .../fdroid/views/appdetails/RepoChooser.kt | 52 ++----------------- .../fdroid/views/repos/RepoIconComposable.kt | 35 +++++++++++++ .../fdroid/views/repos/RepoPreviewScreen.kt | 18 +------ 3 files changed, 42 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/org/fdroid/fdroid/views/repos/RepoIconComposable.kt diff --git a/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt b/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt index b95498396..f29a18d05 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt +++ b/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt @@ -1,7 +1,6 @@ package org.fdroid.fdroid.views.appdetails import android.content.res.Configuration -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement.spacedBy @@ -32,8 +31,6 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Alignment.Companion.End import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -41,17 +38,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.core.content.res.ResourcesCompat import androidx.core.os.LocaleListCompat import androidx.core.util.Consumer -import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi -import com.bumptech.glide.integration.compose.GlideImage -import com.google.accompanist.drawablepainter.rememberDrawablePainter import org.fdroid.database.Repository import org.fdroid.fdroid.R -import org.fdroid.fdroid.Utils import org.fdroid.fdroid.compose.ComposeUtils.FDroidContent import org.fdroid.fdroid.compose.ComposeUtils.FDroidOutlineButton +import org.fdroid.fdroid.views.repos.RepoIcon import org.fdroid.index.IndexFormatVersion.TWO /** @@ -109,7 +102,6 @@ fun RepoChooser( } @Composable -@OptIn(ExperimentalGlideComposeApi::class) private fun RepoDropDown( repos: List, currentRepoId: Long, @@ -121,16 +113,13 @@ private fun RepoDropDown( var expanded by remember { mutableStateOf(false) } val currentRepo = repos.find { it.repoId == currentRepoId } ?: error("Current repoId not in list") - val localeList = LocaleListCompat.getDefault() - val res = LocalContext.current.resources - Column( modifier = modifier.fillMaxWidth(), ) { Box { OutlinedTextField( value = TextFieldValue(buildAnnotatedString { - append(currentRepo.getName(localeList) ?: "Unknown Repository") + append(currentRepo.getName(LocaleListCompat.getDefault()) ?: "Unknown Repository") if (currentRepo.repoId == preferredRepoId) { append(" ") pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) @@ -144,23 +133,7 @@ private fun RepoDropDown( Text(stringResource(R.string.app_details_repositories)) }, leadingIcon = { - if (LocalInspectionMode.current) Image( - painter = rememberDrawablePainter( - ResourcesCompat.getDrawable(res, R.drawable.ic_launcher, null) - ), - contentDescription = null, - modifier = Modifier.size(24.dp), - ) else GlideImage( - model = Utils.getDownloadRequest( - currentRepo, - currentRepo.getIcon(localeList) - ), - contentDescription = null, - modifier = Modifier.size(24.dp), - ) { - it.fallback(R.drawable.ic_repo_app_default) - .error(R.drawable.ic_repo_app_default) - } + RepoIcon(repo = currentRepo, modifier = Modifier.size(24.dp)) }, trailingIcon = { Icon( @@ -206,31 +179,16 @@ private fun RepoDropDown( } @Composable -@OptIn(ExperimentalGlideComposeApi::class) private fun RepoItem(repo: Repository, isPreferred: Boolean, modifier: Modifier = Modifier) { Row( horizontalArrangement = spacedBy(8.dp), verticalAlignment = CenterVertically, modifier = modifier, ) { - val localeList = LocaleListCompat.getDefault() - val res = LocalContext.current.resources - if (LocalInspectionMode.current) Image( - painter = rememberDrawablePainter( - ResourcesCompat.getDrawable(res, R.drawable.ic_launcher, null) - ), - contentDescription = null, - modifier = Modifier.size(24.dp), - ) else GlideImage( - model = Utils.getDownloadRequest(repo, repo.getIcon(localeList)), - contentDescription = null, - modifier = Modifier.size(24.dp), - ) { - it.fallback(R.drawable.ic_repo_app_default).error(R.drawable.ic_repo_app_default) - } + RepoIcon(repo, Modifier.size(24.dp)) Text( text = buildAnnotatedString { - append(repo.getName(localeList) ?: "Unknown Repository") + append(repo.getName(LocaleListCompat.getDefault()) ?: "Unknown Repository") if (isPreferred) { append(" ") pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoIconComposable.kt b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoIconComposable.kt new file mode 100644 index 000000000..7da226fc9 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoIconComposable.kt @@ -0,0 +1,35 @@ +package org.fdroid.fdroid.views.repos + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.core.content.res.ResourcesCompat.getDrawable +import androidx.core.os.LocaleListCompat +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import org.fdroid.database.Repository +import org.fdroid.fdroid.R +import org.fdroid.fdroid.Utils.getDownloadRequest + +@Composable +@OptIn(ExperimentalGlideComposeApi::class) +fun RepoIcon(repo: Repository, modifier: Modifier = Modifier) { + if (LocalInspectionMode.current) Image( + painter = rememberDrawablePainter( + getDrawable(LocalContext.current.resources, R.drawable.ic_launcher, null) + ), + contentDescription = null, + modifier = modifier, + ) else GlideImage( + model = getDownloadRequest(repo, repo.getIcon(LocaleListCompat.getDefault())), + contentDescription = null, + modifier = modifier, + ) { requestBuilder -> + requestBuilder + .fallback(R.drawable.ic_repo_app_default) + .error(R.drawable.ic_repo_app_default) + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoPreviewScreen.kt b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoPreviewScreen.kt index dad1ee287..635c7030c 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoPreviewScreen.kt +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoPreviewScreen.kt @@ -89,7 +89,6 @@ fun RepoPreviewScreen(paddingValues: PaddingValues, state: Fetching, onAddRepo: } @Composable -@OptIn(ExperimentalGlideComposeApi::class) fun RepoPreviewHeader( state: Fetching, onAddRepo: () -> Unit, @@ -101,24 +100,11 @@ fun RepoPreviewHeader( modifier = Modifier.fillMaxWidth(), ) { val repo = state.repo ?: error("repo was null") - val res = LocalContext.current.resources Row( horizontalArrangement = spacedBy(8.dp), verticalAlignment = CenterVertically, ) { - if (isPreview) Image( - painter = rememberDrawablePainter( - getDrawable(res, R.drawable.ic_launcher, null) - ), - contentDescription = null, - modifier = Modifier.size(48.dp), - ) else GlideImage( - model = getDownloadRequest(repo, repo.getIcon(localeList)), - contentDescription = null, - modifier = Modifier.size(48.dp), - ) { - it.fallback(R.drawable.ic_repo_app_default).error(R.drawable.ic_repo_app_default) - } + RepoIcon(repo, Modifier.size(48.dp)) Column(horizontalAlignment = Alignment.Start) { Text( text = repo.getName(localeList) ?: "Unknown Repository", @@ -132,7 +118,7 @@ fun RepoPreviewHeader( modifier = Modifier.alpha(ContentAlpha.medium), ) Text( - text = Utils.formatLastUpdated(res, repo.timestamp), + text = Utils.formatLastUpdated(LocalContext.current.resources, repo.timestamp), style = MaterialTheme.typography.body2, ) } From 38424792595fda1dd45233e030cd95a56b15aa1a Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 8 Nov 2023 11:50:14 -0300 Subject: [PATCH 17/33] [app] Show/consider only versions from selected repo in app details --- .../fdroid/views/AppDetailsActivity.java | 63 +++---- .../views/AppDetailsRecyclerViewAdapter.java | 79 +++++---- .../views/appdetails/AppDetailsViewModel.kt | 159 ++++++++++++++++++ .../main/res/layout/app_details2_header.xml | 2 +- .../main/res/layout/app_details2_loading.xml | 13 ++ .../res/layout/app_details2_version_item.xml | 10 -- app/src/main/res/values/strings.xml | 1 - 7 files changed, 243 insertions(+), 84 deletions(-) create mode 100644 app/src/main/java/org/fdroid/fdroid/views/appdetails/AppDetailsViewModel.kt create mode 100644 app/src/main/res/layout/app_details2_loading.xml diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java index dc5c20d3c..f81ff6f71 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java @@ -43,6 +43,7 @@ import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.content.res.AppCompatResources; import androidx.core.util.ObjectsCompat; +import androidx.lifecycle.ViewModelProvider; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -53,7 +54,6 @@ import com.google.android.material.appbar.MaterialToolbar; import org.fdroid.database.AppPrefs; import org.fdroid.database.AppVersion; -import org.fdroid.database.FDroidDatabase; import org.fdroid.database.Repository; import org.fdroid.fdroid.AppUpdateStatusManager; import org.fdroid.fdroid.CompatibilityChecker; @@ -64,20 +64,19 @@ import org.fdroid.fdroid.R; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.Apk; import org.fdroid.fdroid.data.App; -import org.fdroid.fdroid.data.DBHelper; import org.fdroid.fdroid.installer.ErrorDialogActivity; import org.fdroid.fdroid.installer.InstallManagerService; import org.fdroid.fdroid.installer.Installer; import org.fdroid.fdroid.installer.InstallerFactory; import org.fdroid.fdroid.installer.InstallerService; import org.fdroid.fdroid.nearby.PublicSourceDirProvider; +import org.fdroid.fdroid.views.appdetails.AppData; +import org.fdroid.fdroid.views.appdetails.AppDetailsViewModel; import org.fdroid.fdroid.views.apps.FeatureImage; -import org.fdroid.index.RepoManager; import java.util.ArrayList; import java.util.Iterator; import java.util.List; -import java.util.Objects; public class AppDetailsActivity extends AppCompatActivity implements AppDetailsRecyclerViewAdapter.AppDetailsRecyclerViewAdapterCallbacks { @@ -90,7 +89,7 @@ public class AppDetailsActivity extends AppCompatActivity private static final int REQUEST_UNINSTALL_DIALOG = 4; private FDroidApp fdroidApp; - private FDroidDatabase db; + private AppDetailsViewModel model; private volatile App app; @Nullable private volatile List versions; @@ -132,6 +131,7 @@ public class AppDetailsActivity extends AppCompatActivity AppCompatResources.getDrawable(toolbar.getContext(), R.drawable.ic_more_with_background) ); + model = new ViewModelProvider(this).get(AppDetailsViewModel.class); localBroadcastManager = LocalBroadcastManager.getInstance(this); recyclerView = findViewById(R.id.rvDetails); @@ -144,6 +144,7 @@ public class AppDetailsActivity extends AppCompatActivity finish(); return; } + model.loadApp(packageName); recyclerView.setLayoutManager(lm); recyclerView.setAdapter(adapter); @@ -153,10 +154,9 @@ public class AppDetailsActivity extends AppCompatActivity return true; }); checker = new CompatibilityChecker(this); - db = DBHelper.getDb(getApplicationContext()); - db.getAppDao().getApp(packageName).observe(this, this::onAppChanged); - db.getVersionDao().getAppVersions(packageName).observe(this, this::onVersionsChanged); - db.getAppPrefsDao().getAppPrefs(packageName).observe(this, this::onAppPrefsChanged); + model.getApp().observe(this, this::onAppChanged); + model.getAppData().observe(this, this::onAppDataChanged); + model.getVersions().observe(this, this::onVersionsChanged); } private String getPackageNameFromIntent(Intent intent) { @@ -319,22 +319,13 @@ public class AppDetailsActivity extends AppCompatActivity } return true; } else if (item.getItemId() == R.id.action_ignore_all) { - final AppPrefs prefs = Objects.requireNonNull(appPrefs); - Utils.runOffUiThread(() -> db.getAppPrefsDao().update(prefs.toggleIgnoreAllUpdates())); - AppUpdateStatusManager.getInstance(this).checkForUpdates(); + model.ignoreAllUpdates(); return true; } else if (item.getItemId() == R.id.action_ignore_this) { - final AppPrefs prefs = Objects.requireNonNull(appPrefs); - Utils.runOffUiThread(() -> - db.getAppPrefsDao().update(prefs.toggleIgnoreVersionCodeUpdate(app.autoInstallVersionCode))); - AppUpdateStatusManager.getInstance(this).checkForUpdates(); + model.ignoreVersionCodeUpdate(app.autoInstallVersionCode); return true; } else if (item.getItemId() == R.id.action_release_channel_beta) { - final AppPrefs prefs = Objects.requireNonNull(appPrefs); - Utils.runOffUiThread(() -> { - db.getAppPrefsDao().update(prefs.toggleReleaseChannel(Apk.RELEASE_CHANNEL_BETA)); - return true; // we don't really care about the result here - }, result -> AppUpdateStatusManager.getInstance(this).checkForUpdates()); + model.toggleBetaReleaseChannel(); return true; } else if (item.getItemId() == android.R.id.home) { onBackPressed(); @@ -709,10 +700,12 @@ public class AppDetailsActivity extends AppCompatActivity if (app != null && appPrefs != null) updateAppInfo(app, apks, appPrefs); } - private void onAppPrefsChanged(AppPrefs appPrefs) { - this.appPrefs = appPrefs; - loadRepos(appPrefs.getPreferredRepoId()); - if (app != null) updateAppInfo(app, versions, appPrefs); + private void onAppDataChanged(AppData appData) { + this.appPrefs = appData.getAppPrefs(); + if (appData.getRepos().size() > 0) { + adapter.setRepos(appData.getRepos(), appData.getPreferredRepoId()); + } + updateAppInfo(app, versions, appPrefs); } private void updateAppInfo(App app, @Nullable List apks, AppPrefs appPrefs) { @@ -721,25 +714,11 @@ public class AppDetailsActivity extends AppCompatActivity // If versions are not available, we use an empty list temporarily. List apkList = apks == null ? new ArrayList<>() : apks; app.update(this, apkList, appPrefs); - adapter.updateItems(app, apkList, appPrefs); + adapter.updateItems(app, apks, appPrefs); // pass apks no apkList as null means loading refreshStatus(); supportInvalidateOptionsMenu(); } - private void loadRepos(@Nullable Long preferredRepoId) { - Utils.runOffUiThread(() -> db.getAppDao().getRepositoryIdsForApp(packageName), repoIds -> { - List repoList = new ArrayList<>(repoIds.size()); - RepoManager repoManager = FDroidApp.getRepoManager(this); - if (repoManager.getRepositories().size() <= 2) return; // don't show if only official repo+archive added - for (long repoId: repoIds) { - Repository repo = repoManager.getRepository(repoId); - if (repo != null) repoList.add(repo); - } - long prefId = preferredRepoId == null ? app.repoId : preferredRepoId; - adapter.setRepos(repoList, prefId); - }); - } - @Nullable @SuppressLint("PackageManagerGetSignatures") private PackageInfo getPackageInfo(String packageName) { @@ -806,12 +785,12 @@ public class AppDetailsActivity extends AppCompatActivity @Override public void onRepoChanged(long repoId) { - Utils.runOffUiThread(() -> db.getAppDao().getApp(repoId, app.packageName), this::onAppChanged); + model.selectRepo(repoId); } @Override public void onPreferredRepoChanged(long repoId) { - FDroidApp.getRepoManager(this).setPreferredRepoId(app.packageName, repoId); + model.setPreferredRepo(repoId); } /** diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java index 5e9dec686..3ab5004c6 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java @@ -56,7 +56,6 @@ import com.google.android.material.progressindicator.LinearProgressIndicator; import org.apache.commons.io.FilenameUtils; import org.fdroid.database.AppPrefs; import org.fdroid.database.Repository; -import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.Preferences; import org.fdroid.fdroid.R; import org.fdroid.fdroid.Utils; @@ -113,7 +112,8 @@ public class AppDetailsRecyclerViewAdapter private static final int VIEWTYPE_PERMISSIONS = 4; private static final int VIEWTYPE_VERSIONS = 5; private static final int VIEWTYPE_NO_VERSIONS = 6; - private static final int VIEWTYPE_VERSION = 7; + private static final int VIEWTYPE_VERSIONS_LOADING = 7; + private static final int VIEWTYPE_VERSION = 8; private final Context context; @Nullable @@ -126,6 +126,7 @@ public class AppDetailsRecyclerViewAdapter private Long preferredRepoId = null; private final List versions = new ArrayList<>(); private final List compatibleVersionsDifferentSigner = new ArrayList<>(); + private boolean versionsLoading = true; private boolean showVersions; private HeaderViewHolder headerView; @@ -143,39 +144,44 @@ public class AppDetailsRecyclerViewAdapter addItem(VIEWTYPE_HEADER); } - public void updateItems(@NonNull App app, @NonNull List apks, @NonNull AppPrefs appPrefs) { + public void updateItems(@NonNull App app, @Nullable List apks, @NonNull AppPrefs appPrefs) { this.app = app; + versionsLoading = apks == null; items.clear(); versions.clear(); // Get versions compatibleVersionsDifferentSigner.clear(); - addInstalledApkIfExists(apks); + if (apks != null) addInstalledApkIfExists(apks); boolean showIncompatibleVersions = Preferences.get().showIncompatibleVersions(); - for (final Apk apk : apks) { - boolean allowByCompatibility = apk.compatible || showIncompatibleVersions; - String installedSigner = app.installedSigner; - boolean allowBySigner = installedSigner == null - || showIncompatibleVersions || TextUtils.equals(installedSigner, apk.signer); - if (allowByCompatibility) { - compatibleVersionsDifferentSigner.add(apk); - if (allowBySigner) { - versions.add(apk); - if (!versionsExpandTracker.containsKey(apk.getApkPath())) { - versionsExpandTracker.put(apk.getApkPath(), false); + if (apks != null) { + for (final Apk apk : apks) { + boolean allowByCompatibility = apk.compatible || showIncompatibleVersions; + String installedSigner = app.installedSigner; + boolean allowBySigner = installedSigner == null + || showIncompatibleVersions || TextUtils.equals(installedSigner, apk.signer); + if (allowByCompatibility) { + compatibleVersionsDifferentSigner.add(apk); + if (allowBySigner) { + versions.add(apk); + if (!versionsExpandTracker.containsKey(apk.getApkPath())) { + versionsExpandTracker.put(apk.getApkPath(), false); + } } } } } - suggestedApk = app.findSuggestedApk(apks, appPrefs); + if (apks != null) suggestedApk = app.findSuggestedApk(apks, appPrefs); addItem(VIEWTYPE_HEADER); if (app.getAllScreenshots().size() > 0) addItem(VIEWTYPE_SCREENSHOTS); addItem(VIEWTYPE_DONATE); addItem(VIEWTYPE_LINKS); addItem(VIEWTYPE_PERMISSIONS); - if (versions.isEmpty()) { + if (versionsLoading) { + addItem(VIEWTYPE_VERSIONS_LOADING); + } else if (versions.isEmpty()) { addItem(VIEWTYPE_NO_VERSIONS); } else { addItem(VIEWTYPE_VERSIONS); @@ -332,6 +338,9 @@ public class AppDetailsRecyclerViewAdapter case VIEWTYPE_NO_VERSIONS: View noVersionsView = inflater.inflate(R.layout.app_details2_links, parent, false); return new NoVersionsViewHolder(noVersionsView); + case VIEWTYPE_VERSIONS_LOADING: + View loadingView = inflater.inflate(R.layout.app_details2_loading, parent, false); + return new VersionsLoadingViewHolder(loadingView); case VIEWTYPE_VERSION: View version = inflater.inflate(R.layout.app_details2_version_item, parent, false); return new VersionViewHolder(version); @@ -356,6 +365,7 @@ public class AppDetailsRecyclerViewAdapter case VIEWTYPE_PERMISSIONS: case VIEWTYPE_VERSIONS: case VIEWTYPE_NO_VERSIONS: + case VIEWTYPE_VERSIONS_LOADING: ((AppDetailsViewHolder) holder).bindModel(); break; @@ -674,6 +684,18 @@ public class AppDetailsRecyclerViewAdapter progressLayout.setVisibility(View.GONE); } progressCancel.setOnClickListener(v -> callbacks.installCancel()); + if (versionsLoading) { + progressLayout.setVisibility(View.VISIBLE); + progressLabel.setVisibility(View.GONE); + progressCancel.setVisibility(View.GONE); + progressPercent.setVisibility(View.GONE); + progressBar.setIndeterminate(true); + progressBar.setVisibility(View.VISIBLE); + } else { + progressLabel.setVisibility(View.VISIBLE); + progressCancel.setVisibility(View.VISIBLE); + progressPercent.setVisibility(View.VISIBLE); + } } private void updateAntiFeaturesWarning() { @@ -946,6 +968,16 @@ public class AppDetailsRecyclerViewAdapter } } + private class VersionsLoadingViewHolder extends AppDetailsViewHolder { + VersionsLoadingViewHolder(View itemView) { + super(itemView); + } + + @Override + public void bindModel() { + } + } + private class PermissionsViewHolder extends ExpandableLinearLayoutViewHolder { PermissionsViewHolder(View view) { @@ -1061,7 +1093,6 @@ public class AppDetailsRecyclerViewAdapter final TextView added; final ImageView expandArrow; final View expandedLayout; - final TextView repository; final TextView size; final TextView api; final Button buttonInstallUpgrade; @@ -1082,7 +1113,6 @@ public class AppDetailsRecyclerViewAdapter added = view.findViewById(R.id.added); expandArrow = view.findViewById(R.id.expand_arrow); expandedLayout = view.findViewById(R.id.expanded_layout); - repository = view.findViewById(R.id.repository); size = view.findViewById(R.id.size); api = view.findViewById(R.id.api); buttonInstallUpgrade = view.findViewById(R.id.button_install_upgrade); @@ -1135,19 +1165,9 @@ public class AppDetailsRecyclerViewAdapter added.setVisibility(View.INVISIBLE); } - // Repository name, APK size and required Android version - Repository repo = FDroidApp.getRepoManager(context).getRepository(apk.repoId); - if (repo != null) { - repository.setVisibility(View.VISIBLE); - String name = repo.getName(App.getLocales()); - repository.setText(String.format(context.getString(R.string.app_repository), name)); - } else { - repository.setVisibility(View.INVISIBLE); - } size.setText(context.getString(R.string.app_size, Utils.getFriendlySize(apk.size))); api.setText(getApiText(apk)); - // Figuring out whether to show Install or Update button buttonInstallUpgrade.setVisibility(View.GONE); buttonInstallUpgrade.setText(context.getString(R.string.menu_install)); @@ -1294,7 +1314,6 @@ public class AppDetailsRecyclerViewAdapter // This is required to make these labels // auto-scrollable when they are too long version.setSelected(expand); - repository.setSelected(expand); size.setSelected(expand); api.setSelected(expand); } diff --git a/app/src/main/java/org/fdroid/fdroid/views/appdetails/AppDetailsViewModel.kt b/app/src/main/java/org/fdroid/fdroid/views/appdetails/AppDetailsViewModel.kt new file mode 100644 index 000000000..3529b6125 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/appdetails/AppDetailsViewModel.kt @@ -0,0 +1,159 @@ +package org.fdroid.fdroid.views.appdetails + +import android.app.Application +import androidx.annotation.UiThread +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.fdroid.database.App +import org.fdroid.database.AppPrefs +import org.fdroid.database.AppVersion +import org.fdroid.database.Repository +import org.fdroid.fdroid.AppUpdateStatusManager +import org.fdroid.fdroid.FDroidApp +import org.fdroid.fdroid.data.Apk.RELEASE_CHANNEL_BETA +import org.fdroid.fdroid.data.DBHelper + +data class AppData( + val appPrefs: AppPrefs, + val preferredRepoId: Long, + /** + * A list of [Repository]s the app is in. If this is empty, the list doesn't matter, + * because the user only has one repo. + */ + val repos: List, +) + +class AppDetailsViewModel(app: Application) : AndroidViewModel(app) { + + private val _app = MutableLiveData() + val app: LiveData = _app + private val _versions = MutableLiveData>() + val versions: LiveData> = _versions + private val _appData = MutableLiveData() + val appData: LiveData = _appData + + private val db = DBHelper.getDb(app.applicationContext) + private val repoManager = FDroidApp.getRepoManager(app.applicationContext) + private var packageName: String? = null + private var appLiveData: LiveData? = null + private var versionsLiveData: LiveData>? = null + private var appPrefsLiveData: LiveData? = null + private var preferredRepoId: Long? = null + private var repos: List? = null + + @UiThread + fun loadApp(packageName: String) { + if (this.packageName == packageName) return // already set and loaded + if (this.packageName != null && this.packageName != packageName) error { + "Called loadApp() with different packageName." + } + this.packageName = packageName + + // load app and observe changes + // this is a bit hacky, but uses the existing DB API made for old Java code + appLiveData?.removeObserver(onAppChanged) + appLiveData = db.getAppDao().getApp(packageName).also { liveData -> + liveData.observeForever(onAppChanged) + } + // load repos for app, if user have more than one (+ one archive) repo + if (repoManager.getRepositories().size > 2) viewModelScope.launch { + loadRepos(packageName) + } + // load appPrefs + appPrefsLiveData = db.getAppPrefsDao().getAppPrefs(packageName).also { liveData -> + liveData.observeForever(onAppPrefsChanged) + } + } + + override fun onCleared() { + appLiveData?.removeObserver(onAppChanged) + appPrefsLiveData?.removeObserver(onAppPrefsChanged) + versionsLiveData?.removeObserver(onVersionsChanged) + } + + @UiThread + fun selectRepo(repoId: Long) { + appLiveData?.removeObserver(onAppChanged) + viewModelScope.launch(Dispatchers.IO) { + // this will lose observation of changes in the DB, but uses existing API + _app.postValue(db.getAppDao().getApp(repoId, packageName ?: error(""))) + } + tryToPublishAppData() + resetVersionsLiveData(repoId) + } + + @UiThread + fun setPreferredRepo(repoId: Long) { + repoManager.setPreferredRepoId(packageName ?: error(""), repoId) + } + + private val onAppChanged: Observer = Observer { app -> + // set repoIds on first load + if (_app.value == null && app != null) { + preferredRepoId = app.repoId // DB loads preferred repo first + resetVersionsLiveData(app.repoId) + tryToPublishAppData() + } + _app.value = app + } + + private val onAppPrefsChanged: Observer = Observer { appPrefs -> + if (appPrefs.preferredRepoId != null) preferredRepoId = appPrefs.preferredRepoId + tryToPublishAppData() + } + + private val onVersionsChanged: Observer> = Observer { versions -> + _versions.value = versions + } + + private suspend fun loadRepos(packageName: String) = withContext(Dispatchers.IO) { + repos = db.getAppDao().getRepositoryIdsForApp(packageName).mapNotNull { repoId -> + repoManager.getRepository(repoId) + } + tryToPublishAppData() + } + + private fun tryToPublishAppData() { + val data = AppData( + appPrefs = appPrefsLiveData?.value ?: return, + preferredRepoId = preferredRepoId ?: return, + repos = repos ?: emptyList(), + ) + _appData.postValue(data) + } + + private fun resetVersionsLiveData(repoId: Long) { + versionsLiveData?.removeObserver(onVersionsChanged) + val packageName = this.packageName ?: error("packageName not initialized") + versionsLiveData = db.getVersionDao().getAppVersions(repoId, packageName).also { liveData -> + liveData.observeForever(onVersionsChanged) + } + } + + /* AppPrefs methods */ + + fun ignoreAllUpdates() = viewModelScope.launch(Dispatchers.IO) { + val appPrefs = appPrefsLiveData?.value ?: return@launch + db.getAppPrefsDao().update(appPrefs.toggleIgnoreAllUpdates()) + AppUpdateStatusManager.getInstance(getApplication()).checkForUpdates() + } + + fun ignoreVersionCodeUpdate(versionCode: Long) = viewModelScope.launch(Dispatchers.IO) { + val appPrefs = appPrefsLiveData?.value ?: return@launch + db.getAppPrefsDao().update(appPrefs.toggleIgnoreVersionCodeUpdate(versionCode)) + AppUpdateStatusManager.getInstance(getApplication()).checkForUpdates() + } + + fun toggleBetaReleaseChannel() = viewModelScope.launch(Dispatchers.IO) { + val appPrefs = appPrefsLiveData?.value ?: return@launch + db.getAppPrefsDao().update(appPrefs.toggleReleaseChannel(RELEASE_CHANNEL_BETA)) + AppUpdateStatusManager.getInstance(getApplication()).checkForUpdates() + } + +} diff --git a/app/src/main/res/layout/app_details2_header.xml b/app/src/main/res/layout/app_details2_header.xml index 27f0a09d9..4d3fd90c3 100644 --- a/app/src/main/res/layout/app_details2_header.xml +++ b/app/src/main/res/layout/app_details2_header.xml @@ -169,10 +169,10 @@ android:id="@+id/progress_bar" android:layout_width="0dp" android:layout_height="wrap_content" + android:layout_alignWithParentIfMissing="true" android:layout_below="@id/progress_label" android:layout_alignParentStart="true" android:layout_toStartOf="@id/progress_cancel" - android:layout_toLeftOf="@id/progress_cancel" android:indeterminate="true" android:visibility="gone" app:hideAnimationBehavior="outward" diff --git a/app/src/main/res/layout/app_details2_loading.xml b/app/src/main/res/layout/app_details2_loading.xml new file mode 100644 index 000000000..82936ea9a --- /dev/null +++ b/app/src/main/res/layout/app_details2_loading.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/layout/app_details2_version_item.xml b/app/src/main/res/layout/app_details2_version_item.xml index 0ab584c26..e58a0a65a 100644 --- a/app/src/main/res/layout/app_details2_version_item.xml +++ b/app/src/main/res/layout/app_details2_version_item.xml @@ -118,16 +118,6 @@ android:layout_marginRight="8dp" android:layout_marginEnd="8dp"> - - Update File installed to %s F-Droid needs the storage permission to install this to storage. Please allow it on the next screen to proceed with installation. - Repository: %1$s Size: %1$s Could not launch app. From c6d1a74dce732f661cc517154b9119f18397824c Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 16 Nov 2023 10:46:21 -0300 Subject: [PATCH 18/33] [app] Show a frame around repo on app details screen This was voted for in the fdroid-dev channel. --- .../fdroid/views/appdetails/RepoChooser.kt | 85 +++++++------------ 1 file changed, 31 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt b/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt index f29a18d05..72548f979 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt +++ b/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt @@ -34,7 +34,7 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.FontWeight.Companion.Bold import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -81,35 +81,7 @@ fun RepoChooser( onPreferredRepoChanged: (Long) -> Unit, modifier: Modifier = Modifier, ) { - if (repos.isEmpty()) { - // no-op should not happen - } else if (repos.size == 1) { - RepoItem( - repo = repos[0], - isPreferred = false, // don't show "preferred" if the only repo anyway - modifier = Modifier.fillMaxWidth(), - ) - } else { - RepoDropDown( - repos = repos, - currentRepoId = currentRepoId, - preferredRepoId = preferredRepoId, - onRepoChanged = onRepoChanged, - onPreferredRepoChanged = onPreferredRepoChanged, - modifier = modifier, - ) - } -} - -@Composable -private fun RepoDropDown( - repos: List, - currentRepoId: Long, - preferredRepoId: Long, - onRepoChanged: (Repository) -> Unit, - onPreferredRepoChanged: (Long) -> Unit, - modifier: Modifier = Modifier, -) { + if (repos.isEmpty()) return var expanded by remember { mutableStateOf(false) } val currentRepo = repos.find { it.repoId == currentRepoId } ?: error("Current repoId not in list") @@ -118,40 +90,42 @@ private fun RepoDropDown( ) { Box { OutlinedTextField( - value = TextFieldValue(buildAnnotatedString { - append(currentRepo.getName(LocaleListCompat.getDefault()) ?: "Unknown Repository") - if (currentRepo.repoId == preferredRepoId) { - append(" ") - pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) - append("★ ") - append(stringResource(R.string.app_details_repository_preferred)) - } - }), + value = TextFieldValue( + annotatedString = getRepoString( + repo = currentRepo, + isPreferred = repos.size > 1 && currentRepo.repoId == preferredRepoId, + ), + ), textStyle = MaterialTheme.typography.body2, onValueChange = {}, label = { - Text(stringResource(R.string.app_details_repositories)) + if (repos.size == 1) { + Text(stringResource(R.string.app_details_repository)) + } else { + Text(stringResource(R.string.app_details_repositories)) + } }, leadingIcon = { RepoIcon(repo = currentRepo, modifier = Modifier.size(24.dp)) }, trailingIcon = { - Icon( + if (repos.size > 1) Icon( imageVector = Icons.Default.ArrowDropDown, contentDescription = stringResource(R.string.app_details_repository_expand), ) }, singleLine = true, enabled = false, - colors = TextFieldDefaults.outlinedTextFieldColors( // hack to enable clickable + colors = TextFieldDefaults.outlinedTextFieldColors( + // hack to enable clickable disabledTextColor = LocalContentColor.current.copy(LocalContentAlpha.current), disabledBorderColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled), disabledLabelColor = MaterialTheme.colors.onSurface.copy(ContentAlpha.medium), disabledLeadingIconColor = MaterialTheme.colors.onSurface, ), - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = { expanded = true }), + modifier = Modifier.fillMaxWidth().let { + if (repos.size > 1) it.clickable(onClick = { expanded = true }) else it + }, ) DropdownMenu( expanded = expanded, @@ -187,20 +161,23 @@ private fun RepoItem(repo: Repository, isPreferred: Boolean, modifier: Modifier ) { RepoIcon(repo, Modifier.size(24.dp)) Text( - text = buildAnnotatedString { - append(repo.getName(LocaleListCompat.getDefault()) ?: "Unknown Repository") - if (isPreferred) { - append(" ") - pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) - append("★ ") - append(stringResource(R.string.app_details_repository_preferred)) - } - }, + text = getRepoString(repo, isPreferred), style = MaterialTheme.typography.body2, ) } } +@Composable +private fun getRepoString(repo: Repository, isPreferred: Boolean) = buildAnnotatedString { + append(repo.getName(LocaleListCompat.getDefault()) ?: "Unknown Repository") + if (isPreferred) { + append(" ") + pushStyle(SpanStyle(fontWeight = Bold)) + append("★ ") + append(stringResource(R.string.app_details_repository_preferred)) + } +} + @Composable @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) fun RepoChooserSingleRepoPreview() { From 8b20267f344de16484c6c2befb3d307400c22294 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 21 Nov 2023 10:09:57 -0300 Subject: [PATCH 19/33] [app] Warn user about resetting preferred repo when disabling repo --- .../views/repos/ManageReposActivity.java | 38 ++++++++++++------- .../fdroid/views/repos/RepoAdapter.java | 19 +++++++--- app/src/main/res/values/strings.xml | 2 + 3 files changed, 40 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java b/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java index 52105aace..22b0581ac 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java @@ -25,10 +25,10 @@ import android.os.Build; import android.os.Bundle; import android.os.UserManager; import android.util.Log; -import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.NavUtils; import androidx.core.app.TaskStackBuilder; @@ -36,6 +36,7 @@ import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.appbar.MaterialToolbar; +import com.google.android.material.snackbar.Snackbar; import org.fdroid.database.Repository; import org.fdroid.fdroid.AppUpdateStatusManager; @@ -176,21 +177,32 @@ public class ManageReposActivity extends AppCompatActivity implements RepoAdapte * update the repos if you toggled on on. */ @Override - public void onSetEnabled(Repository repo, boolean isEnabled) { - if (repo.getEnabled() != isEnabled) { - Utils.runOffUiThread(() -> repoManager.setRepositoryEnabled(repo.getRepoId(), isEnabled)); - - if (isEnabled) { - UpdateService.updateRepoNow(this, repo.getAddress()); - } else { - AppUpdateStatusManager.getInstance(this).removeAllByRepo(repo.getRepoId()); - // RepoProvider.Helper.purgeApps(this, repo); - String notification = getString(R.string.repo_disabled_notification, repo.getName(App.getLocales())); - Toast.makeText(this, notification, Toast.LENGTH_LONG).show(); - } + public void onToggleEnabled(Repository repo) { + if (repo.getEnabled()) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setMessage(R.string.repo_disable_warning); + builder.setPositiveButton(R.string.repo_disable_warning_button, (dialog, id) -> { + disableRepo(repo); + dialog.dismiss(); + }); + builder.setNegativeButton(R.string.cancel, (dialog, id) -> { + repoAdapter.updateRepoItem(repo); + dialog.cancel(); + }); + builder.show(); + } else { + Utils.runOffUiThread(() -> repoManager.setRepositoryEnabled(repo.getRepoId(), true)); + UpdateService.updateRepoNow(this, repo.getAddress()); } } + private void disableRepo(Repository repo) { + Utils.runOffUiThread(() -> repoManager.setRepositoryEnabled(repo.getRepoId(), false)); + AppUpdateStatusManager.getInstance(this).removeAllByRepo(repo.getRepoId()); + String notification = getString(R.string.repo_disabled_notification, repo.getName(App.getLocales())); + Snackbar.make(findViewById(R.id.list), notification, Snackbar.LENGTH_LONG).setTextMaxLines(3).show(); + } + private static final int SHOW_REPO_DETAILS = 1; private void editRepo(Repository repo) { diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoAdapter.java index 63899391d..56c76a309 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoAdapter.java @@ -29,7 +29,7 @@ public class RepoAdapter extends RecyclerView.Adapter items = new ArrayList<>(); @@ -70,6 +70,15 @@ public class RepoAdapter extends RecyclerView.Adapter { - if (repoItemListener != null) { - repoItemListener.onSetEnabled(repo, isChecked); - } + switchView.setOnClickListener(buttonView -> { + if (repoItemListener != null) repoItemListener.onToggleEnabled(repo); }); FileV2 iconFile = repo.getIcon(LocaleListCompat.getDefault()); if (iconFile == null) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e128b5b2a..7f49070a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -475,6 +475,8 @@ This often occurs with apps installed via Google Play or other sources, if they Disabled "%1$s".\n\nYou will need to re-enable this repository to install apps from it. + Disabling this repository will remove it as \"preferred\" from any apps you may have manually preferred it. + Disable Saved package repository %1$s. Looking for package repository at\n%1$s From 4831cd8a8dfa599b2fd5d7e066c514f95d7d71db Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 21 Nov 2023 10:29:45 -0300 Subject: [PATCH 20/33] [app] Wrap repo items in a card --- app/src/main/res/layout/repo_item.xml | 134 ++++++++++++++------------ 1 file changed, 71 insertions(+), 63 deletions(-) diff --git a/app/src/main/res/layout/repo_item.xml b/app/src/main/res/layout/repo_item.xml index 8fa4f5f68..2f0e6ec84 100644 --- a/app/src/main/res/layout/repo_item.xml +++ b/app/src/main/res/layout/repo_item.xml @@ -1,13 +1,11 @@ - + android:layout_marginHorizontal="8dp" + android:layout_marginVertical="4dp" + android:descendantFocusability="blocksDescendants"> - - + android:orientation="horizontal" + android:padding="8dp"> - + - + android:layout_weight="1" + android:gravity="center_vertical" + android:orientation="vertical" + tools:ignore="RtlSymmetry"> - + - + + + + + + + + + android:layout_height="match_parent" /> - - - + From e47ef72f7526ce4855c2e4fdc1f4e4fd739d1182 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 28 Nov 2023 09:45:04 -0300 Subject: [PATCH 21/33] [db] add archive repo adding to RepoAdder --- .../main/java/org/fdroid/index/RepoManager.kt | 34 +++++++- .../main/java/org/fdroid/repo/RepoAdder.kt | 79 +++++++++++++++---- 2 files changed, 96 insertions(+), 17 deletions(-) diff --git a/libs/database/src/main/java/org/fdroid/index/RepoManager.kt b/libs/database/src/main/java/org/fdroid/index/RepoManager.kt index edea118b2..4e0281fbf 100644 --- a/libs/database/src/main/java/org/fdroid/index/RepoManager.kt +++ b/libs/database/src/main/java/org/fdroid/index/RepoManager.kt @@ -27,6 +27,7 @@ import org.fdroid.repo.RepoAdder import org.fdroid.repo.RepoUriGetter import java.io.File import java.net.Proxy +import java.util.concurrent.CancellationException import java.util.concurrent.CountDownLatch import kotlin.coroutines.CoroutineContext @@ -36,10 +37,9 @@ public class RepoManager @JvmOverloads constructor( private val db: FDroidDatabase, downloaderFactory: DownloaderFactory, httpManager: HttpManager, - private val repoUriBuilder: RepoUriBuilder = defaultRepoUriBuilder, + repoUriBuilder: RepoUriBuilder = defaultRepoUriBuilder, private val coroutineContext: CoroutineContext = Dispatchers.IO, ) { - private val repositoryDao = db.getRepositoryDao() as RepositoryDaoInt private val appPrefsDao = db.getAppPrefsDao() as AppPrefsDaoInt private val tempFileProvider = TempFileProvider { @@ -197,6 +197,36 @@ public class RepoManager @JvmOverloads constructor( } } + /** + * Enables or disabled the archive repo for the given [repository]. + * + * Note that this can throw all kinds of exceptions, + * especially when the given [repository] does not have a (working) archive repository. + * You should catch those and update your UI accordingly. + */ + @WorkerThread + public suspend fun setArchiveRepoEnabled( + repository: Repository, + enabled: Boolean, + proxy: Proxy? = null, + ) { + val cert = repository.certificate ?: error { "$repository has no cert" } + val archiveRepoId = repositoryDao.getArchiveRepoId(cert) + if (enabled) { + if (archiveRepoId == null) { + try { + repoAdder.addArchiveRepo(repository, proxy) + } catch (e: CancellationException) { + if (e.message != "expected") throw e + } + } else { + repositoryDao.setRepositoryEnabled(archiveRepoId, true) + } + } else if (archiveRepoId != null) { + repositoryDao.setRepositoryEnabled(archiveRepoId, false) + } + } + /** * Returns true if the given [uri] belongs to a swap repo. */ diff --git a/libs/database/src/main/java/org/fdroid/repo/RepoAdder.kt b/libs/database/src/main/java/org/fdroid/repo/RepoAdder.kt index 09934ef5a..d160620d3 100644 --- a/libs/database/src/main/java/org/fdroid/repo/RepoAdder.kt +++ b/libs/database/src/main/java/org/fdroid/repo/RepoAdder.kt @@ -6,6 +6,7 @@ import android.os.Build.VERSION.SDK_INT import android.os.UserManager import android.os.UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES import android.os.UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY +import androidx.annotation.AnyThread import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import androidx.core.content.ContextCompat.getSystemService @@ -13,8 +14,10 @@ import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.SerializationException import mu.KotlinLogging import org.fdroid.database.AppOverviewItem @@ -166,21 +169,7 @@ internal class RepoAdder( // try fetching repo with v2 format first and fallback to v1 try { - try { - val repo = - getTempRepo(nUri.uri, IndexFormatVersion.TWO, nUri.username, nUri.password) - val repoFetcher = RepoV2Fetcher( - tempFileProvider, downloaderFactory, httpManager, repoUriBuilder, proxy - ) - repoFetcher.fetchRepo(nUri.uri, repo, receiver, nUri.fingerprint) - } catch (e: NotFoundException) { - log.warn(e) { "Did not find v2 repo, trying v1 now." } - // try to fetch v1 repo - val repo = - getTempRepo(nUri.uri, IndexFormatVersion.ONE, nUri.username, nUri.password) - val repoFetcher = RepoV1Fetcher(tempFileProvider, downloaderFactory, repoUriBuilder) - repoFetcher.fetchRepo(nUri.uri, repo, receiver, nUri.fingerprint) - } + fetchRepo(nUri.uri, nUri.fingerprint, proxy, nUri.username, nUri.password, receiver) } catch (e: SigningException) { log.error(e) { "Error verifying repo with given fingerprint." } addRepoState.value = AddRepoError(INVALID_FINGERPRINT, e) @@ -207,6 +196,31 @@ internal class RepoAdder( } } + private suspend fun fetchRepo( + uri: Uri, + fingerprint: String?, + proxy: Proxy?, + username: String?, + password: String?, + receiver: RepoPreviewReceiver, + ) { + try { + val repo = + getTempRepo(uri, IndexFormatVersion.TWO, username, password) + val repoFetcher = RepoV2Fetcher( + tempFileProvider, downloaderFactory, httpManager, repoUriBuilder, proxy + ) + repoFetcher.fetchRepo(uri, repo, receiver, fingerprint) + } catch (e: NotFoundException) { + log.warn(e) { "Did not find v2 repo, trying v1 now." } + // try to fetch v1 repo + val repo = + getTempRepo(uri, IndexFormatVersion.ONE, username, password) + val repoFetcher = RepoV1Fetcher(tempFileProvider, downloaderFactory, repoUriBuilder) + repoFetcher.fetchRepo(uri, repo, receiver, fingerprint) + } + } + private fun getFetchResult(url: String, repo: Repository): FetchResult { val cert = repo.certificate ?: error("Certificate was null") val existingRepo = repositoryDao.getRepository(cert) @@ -293,6 +307,41 @@ internal class RepoAdder( fetchJob?.cancel() } + @AnyThread + internal suspend fun addArchiveRepo(repo: Repository, proxy: Proxy? = null) = + withContext(coroutineContext) { + if (repo.isArchiveRepo) error { "Repo ${repo.address} is already an archive repo." } + + val address = repo.address.replace(Regex("repo/?$"), "archive") + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + val receiver = object : RepoPreviewReceiver { + override fun onRepoReceived(archiveRepo: Repository) { + // reset the timestamp of the actual repo, + // so a following repo update will pick this up + val newRepo = NewRepository( + name = archiveRepo.repository.name, + icon = archiveRepo.repository.icon ?: emptyMap(), + address = archiveRepo.address, + formatVersion = archiveRepo.formatVersion, + certificate = archiveRepo.certificate ?: error("Repo had no certificate"), + username = archiveRepo.username, + password = archiveRepo.password, + ) + db.runInTransaction { + val repoId = repositoryDao.insert(newRepo) + repositoryDao.setWeight(repoId, repo.weight - 1) + } + cancel("expected") // no need to continue downloading the entire repo + } + + override fun onAppReceived(app: AppOverviewItem) { + // no-op + } + } + val uri = Uri.parse(address) + fetchRepo(uri, repo.fingerprint, proxy, repo.username, repo.password, receiver) + } + private fun hasDisallowInstallUnknownSources(context: Context): Boolean { val userManager = getSystemService(context, UserManager::class.java) ?: error("No UserManager available.") From 349f386d924f0b5a340901cfb4e39d9d33cc0be2 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 3 Nov 2023 14:50:54 -0300 Subject: [PATCH 22/33] [app] move archive repos to toggle of main repo archive repos can now be enabled/disabled in the details screen of each repo --- app/build.gradle | 1 + .../views/repos/ManageReposActivity.java | 4 +- .../fdroid/views/repos/RepoAdapter.java | 6 ++ .../views/repos/RepoDetailsActivity.java | 19 +++++ .../views/repos/RepoDetailsViewModel.kt | 73 +++++++++++++++++++ .../main/res/layout/activity_repo_details.xml | 6 ++ app/src/main/res/values/strings.xml | 2 + 7 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsViewModel.kt diff --git a/app/build.gradle b/app/build.gradle index 7133161b2..aaf0a718a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -167,6 +167,7 @@ dependencies { implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.vectordrawable:vectordrawable:1.1.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2' implementation 'androidx.palette:palette:1.0.0' implementation 'androidx.work:work-runtime:2.8.1' implementation 'com.google.guava:guava:31.0-android' // somehow needed for work-runtime to function diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java b/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java index 22b0581ac..34496ce04 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java @@ -47,6 +47,8 @@ import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.App; import org.fdroid.index.RepoManager; +import java.util.ArrayList; + import io.reactivex.rxjava3.disposables.CompositeDisposable; public class ManageReposActivity extends AppCompatActivity implements RepoAdapter.RepoItemListener { @@ -139,7 +141,7 @@ public class ManageReposActivity extends AppCompatActivity implements RepoAdapte touchHelper.attachToRecyclerView(repoList); repoList.setAdapter(repoAdapter); FDroidApp.getRepoManager(this).getLiveRepositories().observe(this, items -> { - repoAdapter.updateItems(items); + repoAdapter.updateItems(new ArrayList<>(items)); // copy list, so we don't modify original in adapter isItemReorderingEnabled = true; }); } diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoAdapter.java index 56c76a309..c95dd2ab4 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoAdapter.java @@ -23,6 +23,7 @@ import org.fdroid.index.v2.FileV2; import java.util.ArrayList; import java.util.List; +import java.util.ListIterator; public class RepoAdapter extends RecyclerView.Adapter { @@ -48,6 +49,11 @@ public class RepoAdapter extends RecyclerView.Adapter items) { this.items.clear(); + // filter out archive repos + ListIterator iterator = items.listIterator(); + while (iterator.hasNext()) { + if (iterator.next().isArchiveRepo()) iterator.remove(); + } this.items.addAll(items); notifyDataSetChanged(); } diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsActivity.java b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsActivity.java index 85670a9c2..79aecdda6 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsActivity.java @@ -29,8 +29,10 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.SwitchCompat; import androidx.core.app.NavUtils; import androidx.core.content.ContextCompat; +import androidx.lifecycle.ViewModelProvider; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -105,7 +107,10 @@ public class RepoDetailsActivity extends AppCompatActivity { private MirrorAdapter adapterToNotify; + private RepoDetailsViewModel model; + // FIXME access to this could be moved into ViewModel private RepositoryDao repositoryDao; + // FIXME access to this could be moved into ViewModel private AppDao appDao; @Nullable private Disposable disposable; @@ -128,6 +133,7 @@ public class RepoDetailsActivity extends AppCompatActivity { fdroidApp.setSecureWindow(this); fdroidApp.applyPureBlackBackgroundInDarkTheme(this); + model = new ViewModelProvider(this).get(RepoDetailsViewModel.class); repositoryDao = DBHelper.getDb(this).getRepositoryDao(); appDao = DBHelper.getDb(this).getAppDao(); @@ -142,6 +148,7 @@ public class RepoDetailsActivity extends AppCompatActivity { repoView = findViewById(R.id.repo_view); repoId = getIntent().getLongExtra(ARG_REPO_ID, 0); + model.initRepo(repoId); repo = FDroidApp.getRepoManager(this).getRepository(repoId); TextView inputUrl = findViewById(R.id.input_repo_url); @@ -179,6 +186,18 @@ public class RepoDetailsActivity extends AppCompatActivity { qrCode.setImageBitmap(bitmap); } }); + + SwitchCompat switchCompat = findViewById(R.id.archiveRepo); + model.getLiveData().observe(this, s -> { + Boolean enabled = s.getArchiveEnabled(); + if (enabled == null) { + switchCompat.setEnabled(false); + } else { + switchCompat.setEnabled(true); + switchCompat.setChecked(enabled); + } + }); + switchCompat.setOnClickListener(v -> model.setArchiveRepoEnabled(repo, switchCompat.isChecked())); } @Override diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsViewModel.kt b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsViewModel.kt new file mode 100644 index 000000000..42381e393 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsViewModel.kt @@ -0,0 +1,73 @@ +package org.fdroid.fdroid.views.repos + +import android.app.Application +import android.util.Log +import android.widget.Toast +import android.widget.Toast.LENGTH_SHORT +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import info.guardianproject.netcipher.NetCipher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.fdroid.database.Repository +import org.fdroid.fdroid.FDroidApp +import org.fdroid.fdroid.R +import org.fdroid.fdroid.UpdateService + +data class RepoDetailsState( + val repo: Repository?, + val archiveEnabled: Boolean? = null, +) + +class RepoDetailsViewModel(app: Application) : AndroidViewModel(app) { + + private val repoManager = FDroidApp.getRepoManager(app) + private val _state = MutableStateFlow(null) + val state = _state.asStateFlow() + val liveData = _state.asLiveData() + + fun initRepo(repoId: Long) { + val repo = repoManager.getRepository(repoId) + if (repo == null) { + _state.value = RepoDetailsState(null) + } else { + _state.value = RepoDetailsState( + repo = repo, + archiveEnabled = repo.isArchiveEnabled(), + ) + } + } + + fun setArchiveRepoEnabled(repo: Repository, enabled: Boolean) { + // archiveEnabled = null means we don't know current state, it's in progress + _state.value = _state.value?.copy(archiveEnabled = null) + viewModelScope.launch(Dispatchers.IO) { + try { + repoManager.setArchiveRepoEnabled(repo, enabled, NetCipher.getProxy()) + _state.value = _state.value?.copy(archiveEnabled = enabled) + if (enabled) withContext(Dispatchers.Main) { + val address = repo.address.replace(Regex("repo/?$"), "archive") + UpdateService.updateRepoNow(getApplication(), address) + } + } catch (e: Exception) { + Log.e(this.javaClass.simpleName, "Error toggling archive repo: ", e) + _state.value = _state.value?.copy(archiveEnabled = repo.isArchiveEnabled()) + withContext(Dispatchers.Main) { + Toast.makeText(getApplication(), R.string.repo_archive_failed, LENGTH_SHORT) + .show() + } + } + } + } + + private fun Repository.isArchiveEnabled(): Boolean { + return repoManager.getRepositories().find { r -> + r.isArchiveRepo && r.certificate == certificate + }?.enabled ?: false + } + +} diff --git a/app/src/main/res/layout/activity_repo_details.xml b/app/src/main/res/layout/activity_repo_details.xml index 9f7800ed7..894dfb613 100644 --- a/app/src/main/res/layout/activity_repo_details.xml +++ b/app/src/main/res/layout/activity_repo_details.xml @@ -161,6 +161,12 @@ android:layout_height="wrap_content" android:text="@string/repo_edit_credentials" /> + + Address Number of apps Show apps + Repository Archive Fingerprint of the signing key (SHA-256) Description Last update @@ -467,6 +468,7 @@ This often occurs with apps installed via Google Play or other sources, if they You need to enable it to view the apps it provides. Unknown + Archive repo currently not available Delete Repository? Deleting a repository means apps from it will no longer be available.\n\nNote: All From b45efadc459d950fb37d2c720cb08fd4e957e74e Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 1 Dec 2023 12:12:32 -0300 Subject: [PATCH 23/33] [app] show repo list info dialog in ManageReposActivity --- .../views/repos/ManageReposActivity.java | 24 +++++++++++++++++++ app/src/main/res/drawable/ic_info.xml | 10 ++++++++ app/src/main/res/menu/repo_list.xml | 12 ++++++++++ app/src/main/res/values/strings.xml | 2 ++ 4 files changed, 48 insertions(+) create mode 100644 app/src/main/res/drawable/ic_info.xml create mode 100644 app/src/main/res/menu/repo_list.xml diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java b/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java index 34496ce04..6349c0bb0 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java @@ -25,6 +25,9 @@ import android.os.Build; import android.os.Bundle; import android.os.UserManager; import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -36,6 +39,7 @@ import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.appbar.MaterialToolbar; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.snackbar.Snackbar; import org.fdroid.database.Repository; @@ -198,6 +202,26 @@ public class ManageReposActivity extends AppCompatActivity implements RepoAdapte } } + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.repo_list, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.action_info) { + new MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.repo_list_info_title)) + .setMessage(getString(R.string.repo_list_info_text)) + .setPositiveButton(getString(R.string.ok), (dialog, which) -> dialog.dismiss()) + .show(); + return true; + } + return super.onOptionsItemSelected(item); + } + private void disableRepo(Repository repo) { Utils.runOffUiThread(() -> repoManager.setRepositoryEnabled(repo.getRepoId(), false)); AppUpdateStatusManager.getInstance(this).removeAllByRepo(repo.getRepoId()); diff --git a/app/src/main/res/drawable/ic_info.xml b/app/src/main/res/drawable/ic_info.xml new file mode 100644 index 000000000..fed2fb116 --- /dev/null +++ b/app/src/main/res/drawable/ic_info.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/menu/repo_list.xml b/app/src/main/res/menu/repo_list.xml new file mode 100644 index 000000000..4c2b7b62e --- /dev/null +++ b/app/src/main/res/menu/repo_list.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a99554755..fd0f0336c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -448,6 +448,8 @@ This often occurs with apps installed via Google Play or other sources, if they Recommended only for OLED screens. Unsigned Unverified + Repository List + A repository is a source of apps. This list shows all currently added repositories. Disabled repositories are not used.\n\nIf an app is in more than one repository, the repository higher in the list is automatically preferred. You can reorder repositories by long pressing and dragging them. Repository Address Number of apps From b6a8b9e8e659f82df72aea5d28ff22fd80762058 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 5 Dec 2023 10:01:06 -0300 Subject: [PATCH 24/33] [app] use material progress bar for versions list loading indicator --- app/src/main/res/layout/app_details2_loading.xml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/layout/app_details2_loading.xml b/app/src/main/res/layout/app_details2_loading.xml index 82936ea9a..d59c48811 100644 --- a/app/src/main/res/layout/app_details2_loading.xml +++ b/app/src/main/res/layout/app_details2_loading.xml @@ -1,13 +1,17 @@ - + android:layout_gravity="center" + android:indeterminate="true" + app:hideAnimationBehavior="outward" + app:showAnimationBehavior="inward" + app:showDelay="250" /> From 218a9ebb59ae5c81f66a526006bc22c26c0d0ed3 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 22 Jan 2024 17:51:47 -0300 Subject: [PATCH 25/33] [app] Hide install button in repo app lists These lists show repo versions of the apps with different preferred signers, release channels, etc. The user may not be aware of this, so we force install through repo details screen which has special logic and UI for handling repo preferences. --- .../org/fdroid/fdroid/views/apps/AppListActivity.java | 4 ++++ .../java/org/fdroid/fdroid/views/apps/AppListAdapter.java | 6 ++++++ .../fdroid/fdroid/views/apps/AppListItemController.java | 8 ++++++-- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListActivity.java b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListActivity.java index 0087b59a9..1fbf2b92f 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListActivity.java @@ -278,6 +278,10 @@ public class AppListActivity extends AppCompatActivity implements CategoryTextWa return 0; }); } + // Hide install button, if showing apps from a specific repo, because then we show repo versions + // and do not respect the preferred repo. + // The user may not be aware of this, so we force going through app details. + appAdapter.setHideInstallButton(repoId > 0); appAdapter.setItems(items); if (items.size() > 0) { emptyState.setVisibility(View.GONE); diff --git a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListAdapter.java index 7314bc9e9..516e4d6b3 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListAdapter.java @@ -19,6 +19,7 @@ class AppListAdapter extends RecyclerView.Adapter private final List items = new ArrayList<>(); private Runnable hasHiddenAppsCallback; private final AppCompatActivity activity; + private boolean hideInstallButton = false; AppListAdapter(AppCompatActivity activity) { this.activity = activity; @@ -30,6 +31,10 @@ class AppListAdapter extends RecyclerView.Adapter notifyDataSetChanged(); } + void setHideInstallButton(boolean hide) { + hideInstallButton = hide; + } + void setHasHiddenAppsCallback(Runnable callback) { hasHiddenAppsCallback = callback; } @@ -46,6 +51,7 @@ class AppListAdapter extends RecyclerView.Adapter AppListItem appItem = items.get(position); final App app = new App(appItem); holder.bindModel(app, null, null); + if (hideInstallButton) holder.hideInstallButton(); if (app.isDisabledByAntiFeatures(activity)) { holder.itemView.setVisibility(View.GONE); diff --git a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java index 62fbabcca..b8f244f6d 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java +++ b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java @@ -28,6 +28,8 @@ import androidx.core.util.Pair; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.recyclerview.widget.RecyclerView; +import com.google.android.material.progressindicator.LinearProgressIndicator; + import org.fdroid.database.AppVersion; import org.fdroid.database.DbUpdateChecker; import org.fdroid.database.FDroidDatabase; @@ -48,8 +50,6 @@ import org.fdroid.fdroid.installer.InstallerFactory; import org.fdroid.fdroid.views.AppDetailsActivity; import org.fdroid.fdroid.views.updates.UpdatesAdapter; -import com.google.android.material.progressindicator.LinearProgressIndicator; - import java.io.File; import java.util.Iterator; import java.util.List; @@ -216,6 +216,10 @@ public abstract class AppListItemController extends RecyclerView.ViewHolder { broadcastManager.registerReceiver(onStatusChanged, intentFilter); } + void hideInstallButton() { + if (installButton != null) installButton.setVisibility(View.GONE); + } + /** * To be overridden if required */ From dec7c4d260b730c72ac4d792e80c604b8503a426 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 31 Jan 2024 14:21:37 -0300 Subject: [PATCH 26/33] [db] return error when trying to add archive repo --- libs/database/src/main/java/org/fdroid/repo/RepoAdder.kt | 6 ++++++ .../database/src/main/java/org/fdroid/repo/RepoUriGetter.kt | 6 ++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/libs/database/src/main/java/org/fdroid/repo/RepoAdder.kt b/libs/database/src/main/java/org/fdroid/repo/RepoAdder.kt index d160620d3..e1e468acc 100644 --- a/libs/database/src/main/java/org/fdroid/repo/RepoAdder.kt +++ b/libs/database/src/main/java/org/fdroid/repo/RepoAdder.kt @@ -37,6 +37,7 @@ import org.fdroid.index.TempFileProvider import org.fdroid.repo.AddRepoError.ErrorType.INVALID_FINGERPRINT import org.fdroid.repo.AddRepoError.ErrorType.INVALID_INDEX import org.fdroid.repo.AddRepoError.ErrorType.IO_ERROR +import org.fdroid.repo.AddRepoError.ErrorType.IS_ARCHIVE_REPO import org.fdroid.repo.AddRepoError.ErrorType.UNKNOWN_SOURCES_DISALLOWED import java.io.IOException import java.net.Proxy @@ -82,6 +83,7 @@ public data class AddRepoError( public enum class ErrorType { UNKNOWN_SOURCES_DISALLOWED, INVALID_FINGERPRINT, + IS_ARCHIVE_REPO, INVALID_INDEX, IO_ERROR, } @@ -141,6 +143,10 @@ internal class RepoAdder( addRepoState.value = AddRepoError(INVALID_INDEX, e) return } + if (nUri.uri.lastPathSegment == "archive") { + addRepoState.value = AddRepoError(IS_ARCHIVE_REPO) + return + } // some plumping to receive the repo preview var receivedRepo: Repository? = null diff --git a/libs/database/src/main/java/org/fdroid/repo/RepoUriGetter.kt b/libs/database/src/main/java/org/fdroid/repo/RepoUriGetter.kt index 511a9d81a..ed8b1854a 100644 --- a/libs/database/src/main/java/org/fdroid/repo/RepoUriGetter.kt +++ b/libs/database/src/main/java/org/fdroid/repo/RepoUriGetter.kt @@ -49,10 +49,12 @@ internal object RepoUriGetter { // do some path auto-adding, if it is missing if (pathSegments.size >= 2 && pathSegments[pathSegments.lastIndex - 1] == "fdroid" && - pathSegments.last() == "repo" + (pathSegments.last() == "repo" || pathSegments.last() == "archive") ) { // path already is /fdroid/repo, use as is - } else if (pathSegments.lastOrNull() == "repo") { + } else if (pathSegments.lastOrNull() == "repo" || + pathSegments.lastOrNull() == "archive" + ) { // path already ends in /repo, use as is } else if (pathSegments.size >= 1 && pathSegments.last() == "fdroid") { // path is /fdroid with missing /repo, so add that From de33b4de28ed9bfc54ba2925a431096acba39297 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 31 Jan 2024 14:22:34 -0300 Subject: [PATCH 27/33] [app] Show special error when user tries to add archive repo directly --- .../fdroid/fdroid/views/repos/AddRepoErrorScreen.kt | 10 ++++++++++ app/src/main/res/values/strings.xml | 1 + 2 files changed, 11 insertions(+) diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/AddRepoErrorScreen.kt b/app/src/main/java/org/fdroid/fdroid/views/repos/AddRepoErrorScreen.kt index d83101ac1..c3d1180a2 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/repos/AddRepoErrorScreen.kt +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/AddRepoErrorScreen.kt @@ -31,6 +31,7 @@ import org.fdroid.repo.AddRepoError import org.fdroid.repo.AddRepoError.ErrorType.INVALID_FINGERPRINT import org.fdroid.repo.AddRepoError.ErrorType.INVALID_INDEX import org.fdroid.repo.AddRepoError.ErrorType.IO_ERROR +import org.fdroid.repo.AddRepoError.ErrorType.IS_ARCHIVE_REPO import org.fdroid.repo.AddRepoError.ErrorType.UNKNOWN_SOURCES_DISALLOWED import java.io.IOException @@ -62,6 +63,7 @@ fun AddRepoErrorScreen(paddingValues: PaddingValues, state: AddRepoError) { INVALID_INDEX -> stringResource(R.string.repo_invalid) IO_ERROR -> stringResource(R.string.repo_io_error) + IS_ARCHIVE_REPO -> stringResource(R.string.repo_error_adding_archive) } Text( text = title, @@ -110,3 +112,11 @@ fun AddRepoErrorUnknownSourcesPreview() { AddRepoErrorScreen(PaddingValues(0.dp), AddRepoError(UNKNOWN_SOURCES_DISALLOWED)) } } + +@Preview +@Composable +fun AddRepoErrorArchivePreview() { + ComposeUtils.FDroidContent { + AddRepoErrorScreen(PaddingValues(0.dp), AddRepoError(IS_ARCHIVE_REPO)) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fd0f0336c..7dbb94cc5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -221,6 +221,7 @@ This often occurs with apps installed via Google Play or other sources, if they This is a copy of %1$s, add it as a mirror? Invalid repository.\n\nContact the maintainer and let them know about the issue. Error connecting to the repository. + Archive repositories can not be added directly. Tap the repository in the list and enable the archive there. Could not find repo address in shared text. Bad fingerprint This is not a valid URL. From 8efa13d5622cc7f1b2974df9d024e130f853f674 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 31 Jan 2024 16:27:20 -0300 Subject: [PATCH 28/33] [app] Improve archive toggle in repo details --- app/src/main/res/layout/activity_repo_details.xml | 7 ++++++- app/src/main/res/values/strings.xml | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/layout/activity_repo_details.xml b/app/src/main/res/layout/activity_repo_details.xml index 894dfb613..148d87403 100644 --- a/app/src/main/res/layout/activity_repo_details.xml +++ b/app/src/main/res/layout/activity_repo_details.xml @@ -161,11 +161,16 @@ android:layout_height="wrap_content" android:text="@string/repo_edit_credentials" /> + + + android:text="@string/repo_archive_toggle_description" /> Number of apps Show apps Repository Archive + Show archived apps and outdated versions of apps Fingerprint of the signing key (SHA-256) Description Last update From b0b97a089802bf37adcdcfdae6545ae628a7aac7 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 9 Feb 2024 10:25:56 -0300 Subject: [PATCH 29/33] [app] hide install/update/open buttons in app details when not seeing preferred repo --- .../fdroid/views/AppDetailsRecyclerViewAdapter.java | 11 +++++++++-- app/src/main/res/layout/app_details2_header.xml | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java index 3ab5004c6..fab077b22 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java @@ -469,7 +469,7 @@ public class AppDetailsRecyclerViewAdapter progressLayout.setVisibility(View.GONE); buttonPrimaryView.setVisibility(versions.isEmpty() ? View.GONE : View.VISIBLE); buttonSecondaryView.setVisibility(app != null && app.isUninstallable(context) ? - View.VISIBLE : View.INVISIBLE); + View.VISIBLE : View.GONE); } void setIndeterminateProgress(int resIdString) { @@ -622,7 +622,7 @@ public class AppDetailsRecyclerViewAdapter buttonPrimaryView.setText(R.string.menu_install); buttonPrimaryView.setVisibility(versions.isEmpty() ? View.GONE : View.VISIBLE); buttonSecondaryView.setText(R.string.menu_uninstall); - buttonSecondaryView.setVisibility(app.isUninstallable(context) ? View.VISIBLE : View.INVISIBLE); + buttonSecondaryView.setVisibility(app.isUninstallable(context) ? View.VISIBLE : View.GONE); buttonSecondaryView.setOnClickListener(v -> callbacks.uninstallApk()); if (callbacks.isAppDownloading()) { buttonPrimaryView.setText(R.string.downloading); @@ -696,6 +696,13 @@ public class AppDetailsRecyclerViewAdapter progressCancel.setVisibility(View.VISIBLE); progressPercent.setVisibility(View.VISIBLE); } + // Hide primary buttons when current repo is not the preferred one. + // This requires the user to prefer the repo first, if they want to install/update from it. + if (preferredRepoId != null && preferredRepoId != app.repoId) { + // we don't need to worry about making it visible, because changing current repo refreshes this view + buttonPrimaryView.setVisibility(View.GONE); + buttonSecondaryView.setVisibility(View.GONE); + } } private void updateAntiFeaturesWarning() { diff --git a/app/src/main/res/layout/app_details2_header.xml b/app/src/main/res/layout/app_details2_header.xml index 4d3fd90c3..264475052 100644 --- a/app/src/main/res/layout/app_details2_header.xml +++ b/app/src/main/res/layout/app_details2_header.xml @@ -105,7 +105,7 @@ android:layout_marginEnd="8dp" android:layout_weight="0" android:ellipsize="marquee" - android:visibility="invisible" + android:visibility="gone" app:layout_constraintEnd_toStartOf="@+id/primaryButtonView" app:layout_constraintTop_toBottomOf="@+id/repoChooserView" tools:text="Uninstall" From 81653be6d3a96b21bbcbd923658598ca75fea620 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 9 Feb 2024 10:37:59 -0300 Subject: [PATCH 30/33] [app] remove star icon for preferred repos --- .../java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt b/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt index 72548f979..4f93ab993 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt +++ b/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt @@ -21,7 +21,6 @@ import androidx.compose.material.Text import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.Star import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -144,7 +143,6 @@ fun RepoChooser( if (currentRepo.repoId != preferredRepoId) { FDroidOutlineButton( text = stringResource(R.string.app_details_repository_button_prefer), - imageVector = Icons.Default.Star, onClick = { onPreferredRepoChanged(currentRepo.repoId) }, modifier = Modifier.align(End), ) @@ -173,7 +171,7 @@ private fun getRepoString(repo: Repository, isPreferred: Boolean) = buildAnnotat if (isPreferred) { append(" ") pushStyle(SpanStyle(fontWeight = Bold)) - append("★ ") + append(" ") append(stringResource(R.string.app_details_repository_preferred)) } } From 10601ec0ed186063d1414819f8b2fec9f275d1d3 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 9 Feb 2024 10:38:11 -0300 Subject: [PATCH 31/33] [app] fix NPE race-condition in app details --- .../main/java/org/fdroid/fdroid/views/AppDetailsActivity.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java index f81ff6f71..96482c48a 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java @@ -705,7 +705,7 @@ public class AppDetailsActivity extends AppCompatActivity if (appData.getRepos().size() > 0) { adapter.setRepos(appData.getRepos(), appData.getPreferredRepoId()); } - updateAppInfo(app, versions, appPrefs); + if (app != null) updateAppInfo(app, versions, appPrefs); } private void updateAppInfo(App app, @Nullable List apks, AppPrefs appPrefs) { From 49ac9986405f60394f90f941f8e41746d1b8f810 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 13 Feb 2024 10:51:46 -0300 Subject: [PATCH 32/33] [app] show repo dropdown only if app is in more than one repo or if not in default repo --- .../java/org/fdroid/fdroid/Preferences.java | 18 +++++++++++++++ .../java/org/fdroid/fdroid/data/DBHelper.java | 22 +++++++++++++++++++ .../views/AppDetailsRecyclerViewAdapter.java | 17 +++++++++++--- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/Preferences.java b/app/src/main/java/org/fdroid/fdroid/Preferences.java index d46615515..85260fdc5 100644 --- a/app/src/main/java/org/fdroid/fdroid/Preferences.java +++ b/app/src/main/java/org/fdroid/fdroid/Preferences.java @@ -38,6 +38,7 @@ import androidx.preference.PreferenceManager; import com.google.common.collect.Lists; import org.fdroid.fdroid.data.Apk; +import org.fdroid.fdroid.data.DBHelper; import org.fdroid.fdroid.installer.PrivilegedInstaller; import org.fdroid.fdroid.net.ConnectivityMonitorService; @@ -46,6 +47,7 @@ import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Random; @@ -138,6 +140,7 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh private static final String PREF_HIDE_ON_LONG_PRESS_SEARCH = "hideOnLongPressSearch"; private static final String PREF_HIDE_ALL_NOTIFICATIONS = "hideAllNotifications"; private static final String PREF_SEND_VERSION_AND_UUID_TO_SERVERS = "sendVersionAndUUIDToServers"; + private static final String PREF_DEFAULT_REPO_ADDRESSES = "defaultRepoAddresses"; public static final int OVER_NETWORK_NEVER = 0; private static final int OVER_NETWORK_ON_DEMAND = 1; @@ -751,6 +754,21 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh return gateways; } + private void setPrefDefaultRepoAddresses(Set addresses) { + preferences.edit().putStringSet(PREF_DEFAULT_REPO_ADDRESSES, addresses).apply(); + } + + public Set getDefaultRepoAddresses(Context context) { + Set def = Collections.singleton("empty"); + Set addresses = preferences.getStringSet(PREF_DEFAULT_REPO_ADDRESSES, def); + if (addresses == def) { + Utils.debugLog(TAG, "Parsing XML to get default repo addresses..."); + addresses = new HashSet<>(DBHelper.getDefaultRepoAddresses(context)); + setPrefDefaultRepoAddresses(addresses); + } + return addresses; + } + public void registerAppsRequiringAntiFeaturesChangeListener(ChangeListener listener) { showAppsRequiringAntiFeaturesListeners.add(listener); } diff --git a/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java b/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java index f90deccc3..f0bc60c93 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java +++ b/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java @@ -250,4 +250,26 @@ public class DBHelper { + repoItems.size() + " % " + (REPO_XML_ITEM_COUNT - 1) + " != 0"); return new LinkedList<>(); } + + public static List getDefaultRepoAddresses(Context context) { + List defaultRepos = Arrays.asList(context.getResources().getStringArray(R.array.default_repos)); + if (defaultRepos.size() % REPO_XML_ITEM_COUNT != 0) { + throw new IllegalArgumentException("default_repos.xml has wrong item count: " + + defaultRepos.size() + " % REPO_XML_ARG_COUNT(" + REPO_XML_ITEM_COUNT + + ") != 0, FYI the priority item was removed in v1.16"); + } + List addresses = new ArrayList<>(); + for (int i = 0; i < defaultRepos.size(); i += REPO_XML_ITEM_COUNT) { + boolean enabled = defaultRepos.get(i + 4).equals("1"); + if (!enabled) continue; + // split addresses into a list + for (String address : defaultRepos.get(i + 1).split("\\s+")) { + if (!address.isEmpty()) { + addresses.add(address); + break; // only first one is canonical + } + } + } + return addresses; + } } diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java index fab077b22..4d0a2da8a 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java @@ -56,6 +56,7 @@ import com.google.android.material.progressindicator.LinearProgressIndicator; import org.apache.commons.io.FilenameUtils; import org.fdroid.database.AppPrefs; import org.fdroid.database.Repository; +import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.Preferences; import org.fdroid.fdroid.R; import org.fdroid.fdroid.Utils; @@ -76,6 +77,7 @@ import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Objects; +import java.util.Set; @SuppressWarnings("LineLength") public class AppDetailsRecyclerViewAdapter @@ -549,9 +551,18 @@ public class AppDetailsRecyclerViewAdapter lastUpdateView.setVisibility(View.GONE); } if (app != null && preferredRepoId != null) { - RepoChooserKt.setContentRepoChooser(repoChooserView, repos, app.repoId, preferredRepoId, - repo -> callbacks.onRepoChanged(repo.getRepoId()), callbacks::onPreferredRepoChanged); - repoChooserView.setVisibility(View.VISIBLE); + Set defaultAddresses = Preferences.get().getDefaultRepoAddresses(context); + Repository repo = FDroidApp.getRepoManager(context).getRepository(app.repoId); + // show repo banner, if + // * app is in more than one repo, or + // * app is from a non-default repo + if (repos.size() > 1 || (repo != null && !defaultAddresses.contains(repo.getAddress()))) { + RepoChooserKt.setContentRepoChooser(repoChooserView, repos, app.repoId, preferredRepoId, + r -> callbacks.onRepoChanged(r.getRepoId()), callbacks::onPreferredRepoChanged); + repoChooserView.setVisibility(View.VISIBLE); + } else { + repoChooserView.setVisibility(View.GONE); + } } else { repoChooserView.setVisibility(View.GONE); } From 0bb92cb965dc8d306672ca799489e5bdb2930af9 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 20 Feb 2024 09:42:22 -0300 Subject: [PATCH 33/33] [app] improve RepoChooser drop-down style Uses F-Droid blue if selected repo is preferred and allows multi-line for long repo names --- .../fdroid/views/appdetails/RepoChooser.kt | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt b/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt index 4f93ab993..4663f20ed 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt +++ b/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt @@ -8,8 +8,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.ContentAlpha import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon @@ -30,6 +30,7 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Alignment.Companion.End import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -84,15 +85,21 @@ fun RepoChooser( var expanded by remember { mutableStateOf(false) } val currentRepo = repos.find { it.repoId == currentRepoId } ?: error("Current repoId not in list") + val isPreferred = currentRepo.repoId == preferredRepoId Column( modifier = modifier.fillMaxWidth(), ) { Box { + val borderColor = if (isPreferred) { + colorResource(id = R.color.fdroid_blue) + } else { + LocalContentColor.current.copy(alpha = LocalContentAlpha.current) + } OutlinedTextField( value = TextFieldValue( annotatedString = getRepoString( repo = currentRepo, - isPreferred = repos.size > 1 && currentRepo.repoId == preferredRepoId, + isPreferred = repos.size > 1 && isPreferred, ), ), textStyle = MaterialTheme.typography.body2, @@ -111,20 +118,27 @@ fun RepoChooser( if (repos.size > 1) Icon( imageVector = Icons.Default.ArrowDropDown, contentDescription = stringResource(R.string.app_details_repository_expand), + tint = if (isPreferred) { + colorResource(id = R.color.fdroid_blue) + } else { + LocalContentColor.current.copy(alpha = LocalContentAlpha.current) + }, ) }, - singleLine = true, + singleLine = false, enabled = false, colors = TextFieldDefaults.outlinedTextFieldColors( // hack to enable clickable disabledTextColor = LocalContentColor.current.copy(LocalContentAlpha.current), - disabledBorderColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled), - disabledLabelColor = MaterialTheme.colors.onSurface.copy(ContentAlpha.medium), + disabledBorderColor = borderColor, + disabledLabelColor = borderColor, disabledLeadingIconColor = MaterialTheme.colors.onSurface, ), - modifier = Modifier.fillMaxWidth().let { - if (repos.size > 1) it.clickable(onClick = { expanded = true }) else it - }, + modifier = Modifier + .fillMaxWidth() + .let { + if (repos.size > 1) it.clickable(onClick = { expanded = true }) else it + }, ) DropdownMenu( expanded = expanded, @@ -140,11 +154,11 @@ fun RepoChooser( } } } - if (currentRepo.repoId != preferredRepoId) { + if (!isPreferred) { FDroidOutlineButton( text = stringResource(R.string.app_details_repository_button_prefer), onClick = { onPreferredRepoChanged(currentRepo.repoId) }, - modifier = Modifier.align(End), + modifier = Modifier.align(End).padding(top = 8.dp), ) } }