diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 5127f00a..eef3a104 100755
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -153,6 +153,9 @@ dependencies {
implementation(libs.room.runtime)
ksp(libs.room.compiler)
+ //Security
+ implementation(libs.security.crypto)
+
//Squareup
implementation(libs.moshi)
implementation(libs.moshi.kotlin)
diff --git a/app/schemas/com.saulhdev.feeder.data.db.NeoFeedDb/10.json b/app/schemas/com.saulhdev.feeder.data.db.NeoFeedDb/10.json
new file mode 100644
index 00000000..2a73f281
--- /dev/null
+++ b/app/schemas/com.saulhdev.feeder.data.db.NeoFeedDb/10.json
@@ -0,0 +1,305 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 10,
+ "identityHash": "ed1ff2383676833edd0c9fa8c6933a4b",
+ "entities": [
+ {
+ "tableName": "Feeds",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `url` TEXT NOT NULL, `feedImage` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, `alternateId` INTEGER NOT NULL, `fullTextByDefault` INTEGER NOT NULL, `tag` TEXT NOT NULL, `currentlySyncing` INTEGER NOT NULL, `isEnabled` INTEGER NOT NULL, `sourceType` TEXT NOT NULL DEFAULT 'rss', `requireLink` INTEGER NOT NULL DEFAULT 0, `requireImage` INTEGER NOT NULL DEFAULT 0)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "feedImage",
+ "columnName": "feedImage",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSync",
+ "columnName": "lastSync",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "alternateId",
+ "columnName": "alternateId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fullTextByDefault",
+ "columnName": "fullTextByDefault",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tag",
+ "columnName": "tag",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentlySyncing",
+ "columnName": "currentlySyncing",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isEnabled",
+ "columnName": "isEnabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceType",
+ "columnName": "sourceType",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'rss'"
+ },
+ {
+ "fieldPath": "requireLink",
+ "columnName": "requireLink",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "requireImage",
+ "columnName": "requireImage",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_Feeds_url",
+ "unique": true,
+ "columnNames": [
+ "url"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Feeds_url` ON `${TABLE_NAME}` (`url`)"
+ },
+ {
+ "name": "index_Feeds_id_url_title",
+ "unique": true,
+ "columnNames": [
+ "id",
+ "url",
+ "title"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Feeds_id_url_title` ON `${TABLE_NAME}` (`id`, `url`, `title`)"
+ }
+ ]
+ },
+ {
+ "tableName": "Article",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `plainTitle` TEXT NOT NULL, `imageUrl` TEXT, `enclosureLink` TEXT, `plainSnippet` TEXT NOT NULL, `description` TEXT NOT NULL, `author` TEXT, `pubDateV2` INTEGER NOT NULL DEFAULT 0, `link` TEXT, `feedId` INTEGER NOT NULL, `firstSyncedTime` INTEGER NOT NULL, `primarySortTime` INTEGER NOT NULL, `categories` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, PRIMARY KEY(`uuid`), FOREIGN KEY(`feedId`) REFERENCES `Feeds`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "guid",
+ "columnName": "guid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "plainTitle",
+ "columnName": "plainTitle",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "imageUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "enclosureLink",
+ "columnName": "enclosureLink",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "plainSnippet",
+ "columnName": "plainSnippet",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "author",
+ "columnName": "author",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pubDate",
+ "columnName": "pubDateV2",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "link",
+ "columnName": "link",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "feedId",
+ "columnName": "feedId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "firstSyncedTime",
+ "columnName": "firstSyncedTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "primarySortTime",
+ "columnName": "primarySortTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "categories",
+ "columnName": "categories",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pinned",
+ "columnName": "pinned",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bookmarked",
+ "columnName": "bookmarked",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_Article_feedId_uuid",
+ "unique": true,
+ "columnNames": [
+ "feedId",
+ "uuid"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Article_feedId_uuid` ON `${TABLE_NAME}` (`feedId`, `uuid`)"
+ },
+ {
+ "name": "index_Article_feedId_guid",
+ "unique": false,
+ "columnNames": [
+ "feedId",
+ "guid"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Article_feedId_guid` ON `${TABLE_NAME}` (`feedId`, `guid`)"
+ },
+ {
+ "name": "index_Article_uuid_link",
+ "unique": false,
+ "columnNames": [
+ "uuid",
+ "link"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Article_uuid_link` ON `${TABLE_NAME}` (`uuid`, `link`)"
+ },
+ {
+ "name": "index_Article_feedId",
+ "unique": false,
+ "columnNames": [
+ "feedId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Article_feedId` ON `${TABLE_NAME}` (`feedId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "Feeds",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "feedId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ }
+ ],
+ "views": [
+ {
+ "viewName": "ArticleIdWithLink",
+ "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT Article.uuid, Article.link\n FROM Article\n JOIN feeds f ON Article.feedId = f.id\n WHERE f.fulltextByDefault = 1 OR Article.bookmarked = 1"
+ }
+ ],
+ "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, 'ed1ff2383676833edd0c9fa8c6933a4b')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/schemas/com.saulhdev.feeder.data.db.NeoFeedDb/11.json b/app/schemas/com.saulhdev.feeder.data.db.NeoFeedDb/11.json
new file mode 100644
index 00000000..cedf41a7
--- /dev/null
+++ b/app/schemas/com.saulhdev.feeder.data.db.NeoFeedDb/11.json
@@ -0,0 +1,312 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 11,
+ "identityHash": "3251040f1ade49a8dfb02d4ef527d643",
+ "entities": [
+ {
+ "tableName": "Feeds",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `url` TEXT NOT NULL, `feedImage` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, `alternateId` INTEGER NOT NULL, `fullTextByDefault` INTEGER NOT NULL, `tag` TEXT NOT NULL, `currentlySyncing` INTEGER NOT NULL, `isEnabled` INTEGER NOT NULL, `sourceType` TEXT NOT NULL DEFAULT 'rss', `requireLink` INTEGER NOT NULL DEFAULT 0, `requireImage` INTEGER NOT NULL DEFAULT 0, `excludeReplies` INTEGER NOT NULL DEFAULT 1)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "feedImage",
+ "columnName": "feedImage",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSync",
+ "columnName": "lastSync",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "alternateId",
+ "columnName": "alternateId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fullTextByDefault",
+ "columnName": "fullTextByDefault",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tag",
+ "columnName": "tag",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentlySyncing",
+ "columnName": "currentlySyncing",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isEnabled",
+ "columnName": "isEnabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceType",
+ "columnName": "sourceType",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'rss'"
+ },
+ {
+ "fieldPath": "requireLink",
+ "columnName": "requireLink",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "requireImage",
+ "columnName": "requireImage",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "excludeReplies",
+ "columnName": "excludeReplies",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_Feeds_url",
+ "unique": true,
+ "columnNames": [
+ "url"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Feeds_url` ON `${TABLE_NAME}` (`url`)"
+ },
+ {
+ "name": "index_Feeds_id_url_title",
+ "unique": true,
+ "columnNames": [
+ "id",
+ "url",
+ "title"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Feeds_id_url_title` ON `${TABLE_NAME}` (`id`, `url`, `title`)"
+ }
+ ]
+ },
+ {
+ "tableName": "Article",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `plainTitle` TEXT NOT NULL, `imageUrl` TEXT, `enclosureLink` TEXT, `plainSnippet` TEXT NOT NULL, `description` TEXT NOT NULL, `author` TEXT, `pubDateV2` INTEGER NOT NULL DEFAULT 0, `link` TEXT, `feedId` INTEGER NOT NULL, `firstSyncedTime` INTEGER NOT NULL, `primarySortTime` INTEGER NOT NULL, `categories` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, PRIMARY KEY(`uuid`), FOREIGN KEY(`feedId`) REFERENCES `Feeds`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "guid",
+ "columnName": "guid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "plainTitle",
+ "columnName": "plainTitle",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "imageUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "enclosureLink",
+ "columnName": "enclosureLink",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "plainSnippet",
+ "columnName": "plainSnippet",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "author",
+ "columnName": "author",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pubDate",
+ "columnName": "pubDateV2",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "link",
+ "columnName": "link",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "feedId",
+ "columnName": "feedId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "firstSyncedTime",
+ "columnName": "firstSyncedTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "primarySortTime",
+ "columnName": "primarySortTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "categories",
+ "columnName": "categories",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pinned",
+ "columnName": "pinned",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bookmarked",
+ "columnName": "bookmarked",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_Article_feedId_uuid",
+ "unique": true,
+ "columnNames": [
+ "feedId",
+ "uuid"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Article_feedId_uuid` ON `${TABLE_NAME}` (`feedId`, `uuid`)"
+ },
+ {
+ "name": "index_Article_feedId_guid",
+ "unique": false,
+ "columnNames": [
+ "feedId",
+ "guid"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Article_feedId_guid` ON `${TABLE_NAME}` (`feedId`, `guid`)"
+ },
+ {
+ "name": "index_Article_uuid_link",
+ "unique": false,
+ "columnNames": [
+ "uuid",
+ "link"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Article_uuid_link` ON `${TABLE_NAME}` (`uuid`, `link`)"
+ },
+ {
+ "name": "index_Article_feedId",
+ "unique": false,
+ "columnNames": [
+ "feedId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Article_feedId` ON `${TABLE_NAME}` (`feedId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "Feeds",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "feedId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ }
+ ],
+ "views": [
+ {
+ "viewName": "ArticleIdWithLink",
+ "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT Article.uuid, Article.link\n FROM Article\n JOIN feeds f ON Article.feedId = f.id\n WHERE f.fulltextByDefault = 1 OR Article.bookmarked = 1"
+ }
+ ],
+ "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, '3251040f1ade49a8dfb02d4ef527d643')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/schemas/com.saulhdev.feeder.data.db.NeoFeedDb/9.json b/app/schemas/com.saulhdev.feeder.data.db.NeoFeedDb/9.json
new file mode 100644
index 00000000..95a58c13
--- /dev/null
+++ b/app/schemas/com.saulhdev.feeder.data.db.NeoFeedDb/9.json
@@ -0,0 +1,291 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 9,
+ "identityHash": "871817c742b54d8ee5060ca58515d0b3",
+ "entities": [
+ {
+ "tableName": "Feeds",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `url` TEXT NOT NULL, `feedImage` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, `alternateId` INTEGER NOT NULL, `fullTextByDefault` INTEGER NOT NULL, `tag` TEXT NOT NULL, `currentlySyncing` INTEGER NOT NULL, `isEnabled` INTEGER NOT NULL, `sourceType` TEXT NOT NULL DEFAULT 'rss')",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "feedImage",
+ "columnName": "feedImage",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSync",
+ "columnName": "lastSync",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "alternateId",
+ "columnName": "alternateId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fullTextByDefault",
+ "columnName": "fullTextByDefault",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tag",
+ "columnName": "tag",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentlySyncing",
+ "columnName": "currentlySyncing",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isEnabled",
+ "columnName": "isEnabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceType",
+ "columnName": "sourceType",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'rss'"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_Feeds_url",
+ "unique": true,
+ "columnNames": [
+ "url"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Feeds_url` ON `${TABLE_NAME}` (`url`)"
+ },
+ {
+ "name": "index_Feeds_id_url_title",
+ "unique": true,
+ "columnNames": [
+ "id",
+ "url",
+ "title"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Feeds_id_url_title` ON `${TABLE_NAME}` (`id`, `url`, `title`)"
+ }
+ ]
+ },
+ {
+ "tableName": "Article",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `plainTitle` TEXT NOT NULL, `imageUrl` TEXT, `enclosureLink` TEXT, `plainSnippet` TEXT NOT NULL, `description` TEXT NOT NULL, `author` TEXT, `pubDateV2` INTEGER NOT NULL DEFAULT 0, `link` TEXT, `feedId` INTEGER NOT NULL, `firstSyncedTime` INTEGER NOT NULL, `primarySortTime` INTEGER NOT NULL, `categories` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, PRIMARY KEY(`uuid`), FOREIGN KEY(`feedId`) REFERENCES `Feeds`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "guid",
+ "columnName": "guid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "plainTitle",
+ "columnName": "plainTitle",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "imageUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "enclosureLink",
+ "columnName": "enclosureLink",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "plainSnippet",
+ "columnName": "plainSnippet",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "author",
+ "columnName": "author",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pubDate",
+ "columnName": "pubDateV2",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "link",
+ "columnName": "link",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "feedId",
+ "columnName": "feedId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "firstSyncedTime",
+ "columnName": "firstSyncedTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "primarySortTime",
+ "columnName": "primarySortTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "categories",
+ "columnName": "categories",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pinned",
+ "columnName": "pinned",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bookmarked",
+ "columnName": "bookmarked",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_Article_feedId_uuid",
+ "unique": true,
+ "columnNames": [
+ "feedId",
+ "uuid"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Article_feedId_uuid` ON `${TABLE_NAME}` (`feedId`, `uuid`)"
+ },
+ {
+ "name": "index_Article_feedId_guid",
+ "unique": false,
+ "columnNames": [
+ "feedId",
+ "guid"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Article_feedId_guid` ON `${TABLE_NAME}` (`feedId`, `guid`)"
+ },
+ {
+ "name": "index_Article_uuid_link",
+ "unique": false,
+ "columnNames": [
+ "uuid",
+ "link"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Article_uuid_link` ON `${TABLE_NAME}` (`uuid`, `link`)"
+ },
+ {
+ "name": "index_Article_feedId",
+ "unique": false,
+ "columnNames": [
+ "feedId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Article_feedId` ON `${TABLE_NAME}` (`feedId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "Feeds",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "feedId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ }
+ ],
+ "views": [
+ {
+ "viewName": "ArticleIdWithLink",
+ "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT Article.uuid, Article.link\n FROM Article\n JOIN feeds f ON Article.feedId = f.id\n WHERE f.fulltextByDefault = 1 OR Article.bookmarked = 1"
+ }
+ ],
+ "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, '871817c742b54d8ee5060ca58515d0b3')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5787bf79..4aa8f550 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -34,6 +34,7 @@
@@ -52,6 +53,15 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/saulhdev/feeder/MainActivity.kt b/app/src/main/java/com/saulhdev/feeder/MainActivity.kt
index e659582f..99aaf25d 100644
--- a/app/src/main/java/com/saulhdev/feeder/MainActivity.kt
+++ b/app/src/main/java/com/saulhdev/feeder/MainActivity.kt
@@ -64,6 +64,20 @@ class MainActivity : ComponentActivity() {
configurePeriodicSync()
observePrefs()
+ handleDeepLink(intent)
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ handleDeepLink(intent)
+ }
+
+ private fun handleDeepLink(intent: Intent?) {
+ if (intent == null) return
+ if (::navController.isInitialized) {
+ navController.handleDeepLink(intent)
+ }
}
@Composable
diff --git a/app/src/main/java/com/saulhdev/feeder/NeoApp.kt b/app/src/main/java/com/saulhdev/feeder/NeoApp.kt
index c1c37338..24421eb1 100644
--- a/app/src/main/java/com/saulhdev/feeder/NeoApp.kt
+++ b/app/src/main/java/com/saulhdev/feeder/NeoApp.kt
@@ -13,6 +13,9 @@ import com.saulhdev.feeder.data.content.FeedPreferences.Companion.prefsModule
import com.saulhdev.feeder.data.db.NeoFeedDb
import com.saulhdev.feeder.data.repository.ArticleRepository
import com.saulhdev.feeder.data.repository.SourcesRepository
+import com.saulhdev.feeder.manager.mastodon.MastodonApi
+import com.saulhdev.feeder.manager.mastodon.MastodonAuth
+import com.saulhdev.feeder.manager.mastodon.MastodonStorage
import com.saulhdev.feeder.manager.service.OverlayBridge
import com.saulhdev.feeder.manager.sync.SyncRestClient
import com.saulhdev.feeder.utils.ApplicationCoroutineScope
@@ -20,6 +23,7 @@ import com.saulhdev.feeder.utils.extensions.ToastMaker
import com.saulhdev.feeder.utils.extensions.restartApp
import com.saulhdev.feeder.viewmodels.ArticleListViewModel
import com.saulhdev.feeder.viewmodels.ArticleViewModel
+import com.saulhdev.feeder.viewmodels.MastodonAuthViewModel
import com.saulhdev.feeder.viewmodels.SearchFeedViewModel
import com.saulhdev.feeder.viewmodels.SortFilterViewModel
import com.saulhdev.feeder.viewmodels.SourceEditViewModel
@@ -55,6 +59,7 @@ class NeoApp : MultiDexApplication(), KoinStartup {
viewModelOf(::SourceListViewModel)
viewModelOf(::ArticleViewModel)
viewModelOf(::SortFilterViewModel)
+ viewModelOf(::MastodonAuthViewModel)
}
// TODO Move to its class
@@ -65,6 +70,9 @@ class NeoApp : MultiDexApplication(), KoinStartup {
singleOf(::ArticleRepository)
singleOf(::SourcesRepository)
singleOf(::SyncRestClient)
+ singleOf(::MastodonStorage)
+ singleOf(::MastodonAuth)
+ singleOf(::MastodonApi)
}
private val coreModule = module {
diff --git a/app/src/main/java/com/saulhdev/feeder/data/content/BasePreferences.kt b/app/src/main/java/com/saulhdev/feeder/data/content/BasePreferences.kt
index 9e9b5ddb..ce25cb85 100644
--- a/app/src/main/java/com/saulhdev/feeder/data/content/BasePreferences.kt
+++ b/app/src/main/java/com/saulhdev/feeder/data/content/BasePreferences.kt
@@ -37,7 +37,7 @@ class StringPref(
val icon: ImageVector,
val key: Preferences.Key,
val dataStore: DataStore,
- defaultValue: String = "",
+ val defaultValue: String = "",
val onClick: (() -> Unit)? = null,
val route: NavRoute? = null,
) : PrefDelegate(titleId, summaryId, dataStore, key, defaultValue)
@@ -58,7 +58,9 @@ class StringSetPref(
val icon: ImageVector,
val key: Preferences.Key>,
val dataStore: DataStore,
- val defaultValue: Set = emptySet()
+ val defaultValue: Set = emptySet(),
+ val route: NavRoute? = null,
+ val onClick: (() -> Unit)? = null,
) : PrefDelegate>(titleId, summaryId, dataStore, key, defaultValue)
class BooleanPref(
@@ -121,4 +123,4 @@ abstract class PrefDelegate(
private suspend fun set(value: T) {
dataStore.edit { it[key] = value }
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/saulhdev/feeder/data/content/FeedPreferences.kt b/app/src/main/java/com/saulhdev/feeder/data/content/FeedPreferences.kt
index 5fb4453c..f3cc992b 100644
--- a/app/src/main/java/com/saulhdev/feeder/data/content/FeedPreferences.kt
+++ b/app/src/main/java/com/saulhdev/feeder/data/content/FeedPreferences.kt
@@ -44,6 +44,7 @@ import com.saulhdev.feeder.ui.icons.phosphor.Swatches
import com.saulhdev.feeder.ui.icons.phosphor.WifiHigh
import com.saulhdev.feeder.ui.navigation.NavRoute
import com.saulhdev.feeder.utils.getItemsPerFeed
+import com.saulhdev.feeder.utils.getMastodonItemsPerFeed
import com.saulhdev.feeder.utils.getSortingOptions
import com.saulhdev.feeder.utils.getSyncFrequency
import com.saulhdev.feeder.utils.getSyncRange
@@ -156,6 +157,25 @@ class FeedPreferences private constructor(val context: Context) : KoinComponent
entries = getItemsPerFeed()
)
+ var mastodonItemsPerFeed = StringSelectionPref(
+ titleId = R.string.pref_mastodon_items_per_feed,
+ icon = Phosphor.Hash,
+ key = MASTODON_ITEMS_PER_FEED,
+ dataStore = dataStore,
+ defaultValue = "20",
+ entries = getMastodonItemsPerFeed()
+ )
+
+ var blockedWords = StringSetPref(
+ titleId = R.string.pref_blocked_words,
+ summaryId = R.string.pref_blocked_words_summary,
+ icon = Phosphor.Hash,
+ key = BLOCKED_WORDS,
+ dataStore = dataStore,
+ defaultValue = emptySet(),
+ route = NavRoute.BlockedWords,
+ )
+
/* Others */
var enabledPlugins = StringSetPref(
titleId = R.string.title_plugin_list,
@@ -246,6 +266,8 @@ class FeedPreferences private constructor(val context: Context) : KoinComponent
val SYNC_FREQUENCY = stringPreferencesKey("pref_sync_frequency")
val SYNC_RANGE = stringPreferencesKey("pref_sync_range")
val ITEMS_PER_FEED = stringPreferencesKey("pref_items_per_feed")
+ val MASTODON_ITEMS_PER_FEED = stringPreferencesKey("pref_mastodon_items_per_feed")
+ val BLOCKED_WORDS = stringSetPreferencesKey("pref_blocked_words")
val PLUGINS = stringSetPreferencesKey("pref_enabled_plugins")
val ABOUT = stringPreferencesKey("pref_about")
val DEBUG = booleanPreferencesKey("pref_debugging")
diff --git a/app/src/main/java/com/saulhdev/feeder/data/db/NeoFeedDb.kt b/app/src/main/java/com/saulhdev/feeder/data/db/NeoFeedDb.kt
index 3cb33076..ce9297d3 100644
--- a/app/src/main/java/com/saulhdev/feeder/data/db/NeoFeedDb.kt
+++ b/app/src/main/java/com/saulhdev/feeder/data/db/NeoFeedDb.kt
@@ -46,7 +46,7 @@ const val ID_ALL: Long = -1L
Feed::class,
Article::class,
],
- version = 7,
+ version = 11,
exportSchema = true,
autoMigrations = [
AutoMigration(
@@ -220,7 +220,62 @@ abstract class NeoFeedDb : RoomDatabase() {
class RemoveLegacyPubDate : AutoMigrationSpec
}
-val allMigrations = arrayOf(MIGRATION_1_2, MIGRATION_2_3)
+val allMigrations = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11)
+
+@Suppress("ClassName")
+object MIGRATION_10_11 : Migration(10, 11) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ """
+ ALTER TABLE Feeds ADD COLUMN excludeReplies INTEGER NOT NULL DEFAULT 1
+ """.trimIndent()
+ )
+ }
+}
+
+@Suppress("ClassName")
+object MIGRATION_9_10 : Migration(9, 10) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ """
+ ALTER TABLE Feeds ADD COLUMN requireLink INTEGER NOT NULL DEFAULT 0
+ """.trimIndent()
+ )
+ db.execSQL(
+ """
+ ALTER TABLE Feeds ADD COLUMN requireImage INTEGER NOT NULL DEFAULT 0
+ """.trimIndent()
+ )
+ db.execSQL(
+ """
+ UPDATE Feeds SET requireLink = 1, requireImage = 1 WHERE sourceType = 'mastodon'
+ """.trimIndent()
+ )
+ }
+}
+
+@Suppress("ClassName")
+object MIGRATION_7_8 : Migration(7, 8) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ // No schema changes between 7 and 8.
+ }
+}
+
+@Suppress("ClassName")
+object MIGRATION_8_9 : Migration(8, 9) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ """
+ ALTER TABLE Feeds ADD COLUMN sourceType TEXT NOT NULL DEFAULT 'rss'
+ """.trimIndent()
+ )
+ db.execSQL(
+ """
+ UPDATE Feeds SET sourceType = 'mastodon' WHERE url LIKE 'http://mastodon://%'
+ """.trimIndent()
+ )
+ }
+}
@Suppress("ClassName")
object MIGRATION_1_2 : Migration(1, 2) {
diff --git a/app/src/main/java/com/saulhdev/feeder/data/db/dao/FeedArticleDao.kt b/app/src/main/java/com/saulhdev/feeder/data/db/dao/FeedArticleDao.kt
index 7642cd50..dc84b7a8 100644
--- a/app/src/main/java/com/saulhdev/feeder/data/db/dao/FeedArticleDao.kt
+++ b/app/src/main/java/com/saulhdev/feeder/data/db/dao/FeedArticleDao.kt
@@ -99,6 +99,15 @@ interface FeedArticleDao {
)
fun getAllEnabledFeedArticles(): Flow>
+ @Query(
+ """
+ SELECT Article.* FROM Article
+ JOIN Feeds ON Article.feedId = Feeds.id
+ WHERE Feeds.isEnabled = 1
+ """
+ )
+ suspend fun loadAllEnabledArticles(): List
+
@Query(
"""
SELECT uuid FROM Article
diff --git a/app/src/main/java/com/saulhdev/feeder/data/db/models/Feed.kt b/app/src/main/java/com/saulhdev/feeder/data/db/models/Feed.kt
index 8217f5c1..29143087 100644
--- a/app/src/main/java/com/saulhdev/feeder/data/db/models/Feed.kt
+++ b/app/src/main/java/com/saulhdev/feeder/data/db/models/Feed.kt
@@ -50,4 +50,12 @@ data class Feed(
val tag: String = "",
val currentlySyncing: Boolean = false,
val isEnabled: Boolean = true,
+ @ColumnInfo(defaultValue = "rss")
+ val sourceType: String = "rss",
+ @ColumnInfo(defaultValue = "0")
+ val requireLink: Boolean = false,
+ @ColumnInfo(defaultValue = "0")
+ val requireImage: Boolean = false,
+ @ColumnInfo(defaultValue = "1")
+ val excludeReplies: Boolean = true,
)
\ No newline at end of file
diff --git a/app/src/main/java/com/saulhdev/feeder/data/entity/SourceEditViewState.kt b/app/src/main/java/com/saulhdev/feeder/data/entity/SourceEditViewState.kt
index 72710332..4813656e 100644
--- a/app/src/main/java/com/saulhdev/feeder/data/entity/SourceEditViewState.kt
+++ b/app/src/main/java/com/saulhdev/feeder/data/entity/SourceEditViewState.kt
@@ -28,4 +28,8 @@ data class SourceEditViewState(
val tag: String = "",
val fullTextByDefault: Boolean = true,
val isEnabled: Boolean = true,
+ val sourceType: String = "rss",
+ val requireLink: Boolean = false,
+ val requireImage: Boolean = false,
+ val excludeReplies: Boolean = true,
)
\ No newline at end of file
diff --git a/app/src/main/java/com/saulhdev/feeder/data/repository/ArticleRepository.kt b/app/src/main/java/com/saulhdev/feeder/data/repository/ArticleRepository.kt
index 224d47ec..8a103e05 100644
--- a/app/src/main/java/com/saulhdev/feeder/data/repository/ArticleRepository.kt
+++ b/app/src/main/java/com/saulhdev/feeder/data/repository/ArticleRepository.kt
@@ -22,12 +22,14 @@ import com.saulhdev.feeder.data.db.NeoFeedDb
import com.saulhdev.feeder.data.db.models.Article
import com.saulhdev.feeder.data.db.models.ArticleIdWithLink
import com.saulhdev.feeder.data.db.models.FeedItem
+import com.saulhdev.feeder.utils.blobInputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
+import java.io.File
@OptIn(ExperimentalCoroutinesApi::class)
class ArticleRepository(db: NeoFeedDb) {
@@ -39,6 +41,34 @@ class ArticleRepository(db: NeoFeedDb) {
articlesDao.deleteArticles(ids)
}
+ suspend fun deleteArticlesForFeed(feedId: Long) = withContext(jcc) {
+ articlesDao.deleteFeedArticle(feedId)
+ }
+
+ suspend fun deleteArticlesMatchingWords(words: Set, filesDir: File) = withContext(jcc) {
+ val blocked = words.map { it.lowercase() }.filter { it.isNotBlank() }
+ if (blocked.isEmpty()) return@withContext
+ val articles = articlesDao.loadAllEnabledArticles()
+ val toDelete = articles.mapNotNull { article ->
+ val haystack = buildString {
+ append(article.title)
+ append(article.plainTitle)
+ append(article.description)
+ append(article.plainSnippet)
+ article.author?.let { append(it) }
+ article.link?.let { append(it) }
+ try {
+ blobInputStream(article.uuid, filesDir).bufferedReader().use { append(it.readText()) }
+ } catch (_: Throwable) {
+ }
+ }.lowercase()
+ if (blocked.any { haystack.contains(it) }) article.uuid else null
+ }
+ if (toDelete.isNotEmpty()) {
+ articlesDao.deleteArticles(toDelete)
+ }
+ }
+
suspend fun getArticleByGuid(guid: String, feedId: Long): Article? {
return withContext(jcc) {
articlesDao.loadArticle(guid = guid, feedId = feedId)
diff --git a/app/src/main/java/com/saulhdev/feeder/data/repository/SourcesRepository.kt b/app/src/main/java/com/saulhdev/feeder/data/repository/SourcesRepository.kt
index fb978405..293d68b1 100644
--- a/app/src/main/java/com/saulhdev/feeder/data/repository/SourcesRepository.kt
+++ b/app/src/main/java/com/saulhdev/feeder/data/repository/SourcesRepository.kt
@@ -54,8 +54,8 @@ class SourcesRepository(db: NeoFeedDb) {
feedsDao.insert(feed)
}
- fun updateSource(feed: Feed, resync: Boolean = false) {
- scope.launch {
+ suspend fun updateSource(feed: Feed, resync: Boolean = false) {
+ withContext(jcc) {
if (feedsDao.existsById(feed.id)) {
feedsDao.update(feed)
if (resync) requestFeedSync(feed.id)
diff --git a/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonApi.kt b/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonApi.kt
new file mode 100644
index 00000000..5151fbdf
--- /dev/null
+++ b/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonApi.kt
@@ -0,0 +1,74 @@
+/*
+ * This file is part of Neo Feed
+ * Copyright (c) 2025 Neo Feed Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.saulhdev.feeder.manager.mastodon
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.builtins.ListSerializer
+import kotlinx.serialization.json.Json
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.util.concurrent.TimeUnit
+
+class MastodonApi {
+
+ private val client = OkHttpClient.Builder()
+ .connectTimeout(15, TimeUnit.SECONDS)
+ .writeTimeout(15, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .build()
+
+ private val json = Json { ignoreUnknownKeys = true }
+
+ suspend fun fetchHomeTimeline(
+ instance: String,
+ token: String,
+ limit: Int,
+ ): Result> = withContext(Dispatchers.IO) {
+ runCatching {
+ val request = Request.Builder()
+ .url("https://$instance/api/v1/timelines/home?limit=${limit.coerceIn(1, 200)}")
+ .header("Authorization", "Bearer $token")
+ .build()
+
+ val response = client.newCall(request).execute()
+ val body = response.body?.string()
+ if (!response.isSuccessful || body.isNullOrBlank()) {
+ throw IllegalStateException("Failed to fetch timeline: ${response.code} ${body ?: ""}")
+ }
+ json.decodeFromString(ListSerializer(MastodonStatus.serializer()), body)
+ }
+ }
+
+ suspend fun verifyCredentials(instance: String, token: String): Result = withContext(Dispatchers.IO) {
+ runCatching {
+ val request = Request.Builder()
+ .url("https://$instance/api/v1/accounts/verify_credentials")
+ .header("Authorization", "Bearer $token")
+ .build()
+
+ val response = client.newCall(request).execute()
+ val body = response.body?.string()
+ if (!response.isSuccessful || body.isNullOrBlank()) {
+ throw IllegalStateException("Failed to verify credentials: ${response.code} ${body ?: ""}")
+ }
+ json.decodeFromString(MastodonAccount.serializer(), body)
+ }
+ }
+}
diff --git a/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonAuth.kt b/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonAuth.kt
new file mode 100644
index 00000000..536e4abd
--- /dev/null
+++ b/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonAuth.kt
@@ -0,0 +1,114 @@
+/*
+ * This file is part of Neo Feed
+ * Copyright (c) 2025 Neo Feed Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.saulhdev.feeder.manager.mastodon
+
+import com.saulhdev.feeder.utils.urlEncode
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.Json
+import okhttp3.FormBody
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.util.concurrent.TimeUnit
+
+const val MASTODON_REDIRECT_URI = "nf-mastodon://callback"
+const val MASTODON_SCOPES = "read"
+const val MASTODON_CLIENT_NAME = "Neo Feed"
+const val MASTODON_WEBSITE = "https://github.com/NeoApplications/Neo-Feed"
+
+class MastodonAuth {
+
+ private val client = OkHttpClient.Builder()
+ .connectTimeout(15, TimeUnit.SECONDS)
+ .writeTimeout(15, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .build()
+
+ private val json = Json { ignoreUnknownKeys = true }
+
+ suspend fun registerApp(instance: String): Result = withContext(Dispatchers.IO) {
+ runCatching {
+ val request = Request.Builder()
+ .url("https://$instance/api/v1/apps")
+ .post(
+ FormBody.Builder()
+ .add("client_name", MASTODON_CLIENT_NAME)
+ .add("redirect_uris", MASTODON_REDIRECT_URI)
+ .add("scopes", MASTODON_SCOPES)
+ .add("website", MASTODON_WEBSITE)
+ .build()
+ )
+ .build()
+
+ val response = client.newCall(request).execute()
+ val body = response.body?.string()
+ if (!response.isSuccessful || body.isNullOrBlank()) {
+ throw IllegalStateException("Failed to register app: ${response.code} ${body ?: ""}")
+ }
+ json.decodeFromString(MastodonAppCredentials.serializer(), body)
+ }
+ }
+
+ fun buildAuthorizationUrl(
+ instance: String,
+ clientId: String,
+ redirectUri: String = MASTODON_REDIRECT_URI,
+ state: String,
+ scope: String = MASTODON_SCOPES,
+ ): String {
+ return "https://$instance/oauth/authorize" +
+ "?client_id=${clientId.urlEncode()}" +
+ "&redirect_uri=${redirectUri.urlEncode()}" +
+ "&response_type=code" +
+ "&scope=${scope.urlEncode()}" +
+ "&state=${state.urlEncode()}"
+ }
+
+ suspend fun exchangeCode(
+ instance: String,
+ clientId: String,
+ clientSecret: String,
+ code: String,
+ redirectUri: String = MASTODON_REDIRECT_URI,
+ ): Result = withContext(Dispatchers.IO) {
+ runCatching {
+ val request = Request.Builder()
+ .url("https://$instance/oauth/token")
+ .post(
+ FormBody.Builder()
+ .add("grant_type", "authorization_code")
+ .add("code", code)
+ .add("client_id", clientId)
+ .add("client_secret", clientSecret)
+ .add("redirect_uri", redirectUri)
+ .add("scope", MASTODON_SCOPES)
+ .build()
+ )
+ .build()
+
+ val response = client.newCall(request).execute()
+ val body = response.body?.string()
+ if (!response.isSuccessful || body.isNullOrBlank()) {
+ throw IllegalStateException("Failed to exchange token: ${response.code} ${body ?: ""}")
+ }
+ val token = json.decodeFromString(MastodonToken.serializer(), body)
+ token.accessToken
+ }
+ }
+}
diff --git a/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonFeedParser.kt b/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonFeedParser.kt
new file mode 100644
index 00000000..7741e2c0
--- /dev/null
+++ b/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonFeedParser.kt
@@ -0,0 +1,99 @@
+/*
+ * This file is part of Neo Feed
+ * Copyright (c) 2025 Neo Feed Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.saulhdev.feeder.manager.mastodon
+
+import android.util.Log
+import com.saulhdev.feeder.data.db.models.Article
+import com.saulhdev.feeder.data.db.models.Feed
+import com.saulhdev.feeder.data.repository.ArticleRepository
+import com.saulhdev.feeder.utils.HtmlToPlainTextConverter
+import kotlin.time.Clock
+import kotlin.time.Instant
+
+object MastodonFeedParser {
+
+ private const val TAG = "MastodonFeedParser"
+ private val hrefRegex = """]+href=\"(https?://[^\"]+)\"[^>]*>""".toRegex(RegexOption.IGNORE_CASE)
+
+ suspend fun toArticles(
+ statuses: List,
+ feed: Feed,
+ downloadTime: Instant,
+ articleRepo: ArticleRepository,
+ ): List> {
+ val converter = HtmlToPlainTextConverter()
+ return statuses.mapNotNull { status ->
+ val original = status.reblog ?: status
+
+ if (feed.excludeReplies && original.inReplyToId != null) return@mapNotNull null
+ if (feed.requireLink && !hasExternalLink(original.content)) return@mapNotNull null
+ if (feed.requireImage && !hasPicture(original.mediaAttachments)) return@mapNotNull null
+
+ val guid = status.id
+ val existing = articleRepo.getArticleByGuid(guid, feed.id)
+ val author = original.account.displayName.ifBlank { original.account.acct }
+ val plainSnippet = converter.convert(original.content).take(200)
+ val pubDate = parseDate(status.createdAt)
+ val primarySortTime = if (pubDate > 0L) {
+ minOf(downloadTime, Instant.fromEpochMilliseconds(pubDate))
+ } else {
+ downloadTime
+ }
+ val article = (existing ?: Article(firstSyncedTime = downloadTime)).copy(
+ uuid = existing?.uuid ?: "",
+ guid = guid,
+ title = author,
+ plainTitle = author,
+ description = original.content,
+ plainSnippet = plainSnippet,
+ imageUrl = original.mediaAttachments.firstOrNull()?.previewUrl,
+ author = author,
+ link = original.url ?: original.uri,
+ pubDate = pubDate,
+ primarySortTime = primarySortTime,
+ feedId = feed.id,
+ )
+ article to original.content
+ }
+ }
+
+ private fun hasExternalLink(content: String): Boolean {
+ return hrefRegex.findAll(content).any { match ->
+ val path = match.groupValues[1]
+ .substringAfter("://")
+ .substringAfter('/', "")
+ !(path.startsWith("@") || path.startsWith("tags/", ignoreCase = true))
+ }
+ }
+
+ private fun hasPicture(attachments: List): Boolean {
+ return attachments.any {
+ it.type.equals("image", ignoreCase = true) || it.type.equals("gifv", ignoreCase = true)
+ }
+ }
+
+ private fun parseDate(createdAt: String): Long {
+ return try {
+ Instant.parse(createdAt).toEpochMilliseconds()
+ } catch (e: Throwable) {
+ Log.e(TAG, "Failed to parse date: $createdAt", e)
+ Clock.System.now().toEpochMilliseconds()
+ }
+ }
+}
diff --git a/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonFeedSync.kt b/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonFeedSync.kt
new file mode 100644
index 00000000..959f4a77
--- /dev/null
+++ b/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonFeedSync.kt
@@ -0,0 +1,116 @@
+/*
+ * This file is part of Neo Feed
+ * Copyright (c) 2025 Neo Feed Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.saulhdev.feeder.manager.mastodon
+
+import android.content.Context
+import android.util.Log
+import com.saulhdev.feeder.data.content.FeedPreferences
+import com.saulhdev.feeder.data.db.models.Feed
+import com.saulhdev.feeder.data.repository.ArticleRepository
+import com.saulhdev.feeder.manager.sync.ResponseFailure
+import com.saulhdev.feeder.manager.sync.filterBlockedWords
+import com.saulhdev.feeder.utils.blobFile
+import com.saulhdev.feeder.utils.blobOutputStream
+import com.saulhdev.feeder.utils.getSyncDays
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.datetime.DateTimePeriod
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.minus
+import org.koin.java.KoinJavaComponent.inject
+import java.io.File
+import java.io.IOException
+import kotlin.time.Clock
+import kotlin.time.Instant
+
+private const val TAG = "MastodonFeedSync"
+
+object MastodonFeedSync {
+ private val prefs: FeedPreferences by inject(FeedPreferences::class.java)
+ private val mastodonStorage: MastodonStorage by inject(MastodonStorage::class.java)
+ private val mastodonApi: MastodonApi by inject(MastodonApi::class.java)
+
+ suspend fun sync(
+ context: Context,
+ articleRepo: ArticleRepository,
+ feedSql: Feed,
+ filesDir: File,
+ downloadTime: Instant,
+ ) {
+ val (instance, account) = feedSql.url.toMastodonInstanceAndAccount()
+ ?: throw ResponseFailure("Invalid Mastodon feed URL: ${feedSql.url}")
+
+ val token = mastodonStorage.getAccessToken(instance, account)
+ ?: throw ResponseFailure("No token for ${feedSql.title}")
+
+ val limit = prefs.mastodonItemsPerFeed.getValue().toInt().coerceAtLeast(1)
+
+ val statuses = mastodonApi.fetchHomeTimeline(instance, token, limit).getOrElse {
+ throw ResponseFailure("Mastodon sync failed for ${feedSql.title}: ${it.message}")
+ }
+
+ Log.d(TAG, "Fetched ${statuses.size} Mastodon statuses for ${feedSql.title}")
+
+ val articles = MastodonFeedParser.toArticles(
+ statuses = statuses,
+ feed = feedSql,
+ downloadTime = downloadTime,
+ articleRepo = articleRepo,
+ ).filterBlockedWords()
+
+ Log.d(TAG, "Prepared ${articles.size} Mastodon articles for ${feedSql.title}")
+
+ val days = getSyncDays(prefs)
+ val minKeptPubDate = Clock.System.now().minus(
+ period = DateTimePeriod(days = days),
+ timeZone = TimeZone.currentSystemDefault()
+ ).toEpochMilliseconds()
+
+ val filteredArticles = articles.filter { (article, _) ->
+ article.pubDate !in 1..
+ withContext(Dispatchers.IO) {
+ blobOutputStream(article.uuid, filesDir).bufferedWriter().use {
+ it.write(text)
+ }
+ }
+ }
+
+ val ids = articleRepo.getItemsToBeCleanedFromFeed(
+ feedId = feedSql.id,
+ minKeptPubDate = minKeptPubDate
+ )
+ Log.d(TAG, "Cleanup ${feedSql.title}: days=$days cutoff=$minKeptPubDate deleting=${ids.size}")
+
+ for (id in ids) {
+ val file = blobFile(itemId = id, filesDir = filesDir)
+ try {
+ if (file.isFile) {
+ file.delete()
+ }
+ } catch (e: IOException) {
+ Log.e(TAG, "Failed to delete $file", e)
+ }
+ }
+
+ articleRepo.deleteArticles(ids)
+ }
+}
diff --git a/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonModels.kt b/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonModels.kt
new file mode 100644
index 00000000..295e1651
--- /dev/null
+++ b/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonModels.kt
@@ -0,0 +1,59 @@
+/*
+ * This file is part of Neo Feed
+ * Copyright (c) 2025 Neo Feed Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.saulhdev.feeder.manager.mastodon
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class MastodonAppCredentials(
+ @SerialName("client_id") val clientId: String,
+ @SerialName("client_secret") val clientSecret: String,
+)
+
+@Serializable
+data class MastodonToken(
+ @SerialName("access_token") val accessToken: String,
+)
+
+@Serializable
+data class MastodonAccount(
+ @SerialName("display_name") val displayName: String = "",
+ val acct: String,
+)
+
+@Serializable
+data class MastodonMediaAttachment(
+ @SerialName("preview_url") val previewUrl: String? = null,
+ val url: String? = null,
+ val type: String? = null,
+)
+
+@Serializable
+data class MastodonStatus(
+ val id: String,
+ val uri: String = "",
+ @SerialName("created_at") val createdAt: String,
+ val content: String = "",
+ val url: String? = null,
+ val account: MastodonAccount,
+ @SerialName("media_attachments") val mediaAttachments: List = emptyList(),
+ val reblog: MastodonStatus? = null,
+ @SerialName("in_reply_to_id") val inReplyToId: String? = null,
+)
diff --git a/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonStorage.kt b/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonStorage.kt
new file mode 100644
index 00000000..2588eaa4
--- /dev/null
+++ b/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonStorage.kt
@@ -0,0 +1,103 @@
+/*
+ * This file is part of Neo Feed
+ * Copyright (c) 2025 Neo Feed Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+@file:Suppress("DEPRECATION")
+
+package com.saulhdev.feeder.manager.mastodon
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+
+@Suppress("DEPRECATION")
+class MastodonStorage(context: Context) {
+
+ private val prefs: SharedPreferences by lazy {
+ val masterKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+ EncryptedSharedPreferences.create(
+ context,
+ PREFS_FILE,
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ }
+
+ fun saveAppCredentials(instance: String, clientId: String, clientSecret: String) {
+ prefs.edit()
+ .putString(appClientIdKey(instance), clientId)
+ .putString(appClientSecretKey(instance), clientSecret)
+ .apply()
+ }
+
+ fun getAppCredentials(instance: String): Pair? {
+ val clientId = prefs.getString(appClientIdKey(instance), null) ?: return null
+ val clientSecret = prefs.getString(appClientSecretKey(instance), null) ?: return null
+ return clientId to clientSecret
+ }
+
+ fun savePendingState(state: String, instance: String) {
+ prefs.edit()
+ .putString(KEY_PENDING_STATE, state)
+ .putString(KEY_PENDING_INSTANCE, instance)
+ .apply()
+ }
+
+ fun getPendingInstance(state: String): String? {
+ val pending = prefs.getString(KEY_PENDING_STATE, null)
+ return if (pending == state) {
+ prefs.getString(KEY_PENDING_INSTANCE, null)
+ } else null
+ }
+
+ fun clearPendingState() {
+ prefs.edit()
+ .remove(KEY_PENDING_STATE)
+ .remove(KEY_PENDING_INSTANCE)
+ .apply()
+ }
+
+ fun saveAccessToken(instance: String, account: String, token: String) {
+ prefs.edit()
+ .putString(tokenKey(instance, account), token)
+ .apply()
+ }
+
+ fun getAccessToken(instance: String, account: String): String? {
+ return prefs.getString(tokenKey(instance, account), null)
+ }
+
+ fun deleteAccessToken(instance: String, account: String) {
+ prefs.edit()
+ .remove(tokenKey(instance, account))
+ .apply()
+ }
+
+ private fun appClientIdKey(instance: String) = "app_${instance}_client_id"
+ private fun appClientSecretKey(instance: String) = "app_${instance}_client_secret"
+ private fun tokenKey(instance: String, account: String) = "token_${instance}_${account}"
+
+ companion object {
+ private const val PREFS_FILE = "mastodon_tokens"
+ private const val KEY_PENDING_STATE = "pending_state"
+ private const val KEY_PENDING_INSTANCE = "pending_instance"
+ }
+}
diff --git a/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonUrl.kt b/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonUrl.kt
new file mode 100644
index 00000000..1c193c4a
--- /dev/null
+++ b/app/src/main/java/com/saulhdev/feeder/manager/mastodon/MastodonUrl.kt
@@ -0,0 +1,54 @@
+/*
+ * This file is part of Neo Feed
+ * Copyright (c) 2025 Neo Feed Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.saulhdev.feeder.manager.mastodon
+
+import com.saulhdev.feeder.data.db.models.Feed
+import java.net.URL
+
+private const val STORED_SCHEME_PREFIX = "http://mastodon://"
+
+fun Feed.isMastodon(): Boolean = sourceType == "mastodon"
+
+fun URL.toMastodonInstanceAndAccount(): Pair? {
+ val raw = toString()
+
+ // Legacy storage used a fake http://mastodon:// scheme.
+ if (raw.startsWith(STORED_SCHEME_PREFIX)) {
+ val rest = raw.removePrefix(STORED_SCHEME_PREFIX)
+ val parts = rest.split("/", limit = 2)
+ val instance = parts.getOrNull(0)?.takeIf { it.isNotBlank() } ?: return null
+ val account = parts.getOrNull(1)
+ ?.removePrefix("@")
+ ?.takeIf { it.isNotBlank() }
+ ?: return null
+ return instance to account
+ }
+
+ if (!raw.startsWith("https://", ignoreCase = true)) return null
+ val withoutScheme = raw.removePrefix("https://")
+ val instance = withoutScheme.substringBefore('/', "")
+ .takeIf { it.isNotBlank() } ?: return null
+ val account = withoutScheme.substringAfter('/', "")
+ .removePrefix("@")
+ .takeIf { it.isNotBlank() } ?: return null
+ return instance to account
+}
+
+fun buildMastodonFeedUrl(instance: String, account: String): String =
+ "https://$instance/@$account"
diff --git a/app/src/main/java/com/saulhdev/feeder/manager/service/OverlayView.kt b/app/src/main/java/com/saulhdev/feeder/manager/service/OverlayView.kt
index 6e0fed51..aa396db5 100644
--- a/app/src/main/java/com/saulhdev/feeder/manager/service/OverlayView.kt
+++ b/app/src/main/java/com/saulhdev/feeder/manager/service/OverlayView.kt
@@ -76,6 +76,11 @@ class OverlayView(val context: Context) :
themeHolder = OverlayThemeHolder(this)
+ // Start fully transparent so the overlay never covers the launcher/folders
+ // while it is closed (for example, before the first onScroll callback arrives).
+ val bgColor = themeHolder.currentTheme.get(CardTheme.Colors.OVERLAY_BG.ordinal)
+ getWindow().setBackgroundDrawable(ColorDrawable((bgColor and 0x00ffffff)))
+
initRecyclerView()
initHeader()
refreshNotifications()
@@ -329,8 +334,10 @@ class OverlayView(val context: Context) :
super.onScroll(f)
val bgColor = themeHolder.currentTheme.get(CardTheme.Colors.OVERLAY_BG.ordinal)
- val color =
- (prefs.overlayTransparency.getValue() * 255.0f).toInt() shl 24 or (bgColor and 0x00ffffff)
+ // When the panel is closed (progress 0) the overlay must be fully transparent
+ // so it does not cover the launcher/folders.
+ val alpha = if (f <= 0f) 0f else prefs.overlayTransparency.getValue()
+ val color = (alpha * 255.0f).toInt() shl 24 or (bgColor and 0x00ffffff)
getWindow().setBackgroundDrawable(ColorDrawable(color))
}
diff --git a/app/src/main/java/com/saulhdev/feeder/manager/sync/RssLocalSync.kt b/app/src/main/java/com/saulhdev/feeder/manager/sync/RssLocalSync.kt
index 82ff1922..2859ff1d 100644
--- a/app/src/main/java/com/saulhdev/feeder/manager/sync/RssLocalSync.kt
+++ b/app/src/main/java/com/saulhdev/feeder/manager/sync/RssLocalSync.kt
@@ -28,6 +28,7 @@ import com.saulhdev.feeder.data.db.models.Feed
import com.saulhdev.feeder.data.entity.JsonFeed
import com.saulhdev.feeder.data.repository.ArticleRepository
import com.saulhdev.feeder.data.repository.SourcesRepository
+import com.saulhdev.feeder.manager.mastodon.MastodonFeedSync
import com.saulhdev.feeder.manager.models.FeedParser
import com.saulhdev.feeder.manager.models.getResponse
import com.saulhdev.feeder.manager.models.scheduleFullTextParse
@@ -131,6 +132,7 @@ internal suspend fun syncFeeds(
feedsRepo.setCurrentlySyncingOn(feedId = feed.id, syncing = true)
syncFeed(
+ context = context,
feedsRepo = feedsRepo,
articleRepo = articlesRepo,
feedSql = feed,
@@ -171,6 +173,7 @@ internal suspend fun syncFeeds(
}
private suspend fun syncFeed(
+ context: Context,
feedsRepo: SourcesRepository,
articleRepo: ArticleRepository,
feedSql: Feed,
@@ -181,6 +184,17 @@ private suspend fun syncFeed(
) {
Log.d(TAG, "Fetching ${feedSql.title}")
+ if (feedSql.sourceType == "mastodon") {
+ MastodonFeedSync.sync(
+ context = context,
+ articleRepo = articleRepo,
+ feedSql = feedSql,
+ filesDir = filesDir,
+ downloadTime = downloadTime
+ )
+ return
+ }
+
val okHttpClient = OkHttpClient.Builder()
.build()
val response: Response =
@@ -230,7 +244,8 @@ private suspend fun syncFeed(
}
?.filter { (article, _) ->
article.pubDate !in 1..>.filterBlockedWords(): List> {
+ val blocked = prefs.blockedWords.getValue()
+ .map { it.lowercase() }
+ .filter { it.isNotBlank() }
+ if (blocked.isEmpty()) return this
+ return filter { (article, text) ->
+ val haystack = buildString {
+ append(article.title)
+ append(article.plainTitle)
+ append(article.description)
+ append(article.plainSnippet)
+ article.author?.let { append(it) }
+ article.link?.let { append(it) }
+ append(text)
+ }.lowercase()
+ blocked.none { haystack.contains(it) }
+ }
+}
+
internal suspend fun feedsToSync(
repository: SourcesRepository,
feedId: Long,
diff --git a/app/src/main/java/com/saulhdev/feeder/ui/components/StringSetPreference.kt b/app/src/main/java/com/saulhdev/feeder/ui/components/StringSetPreference.kt
index 5b74f098..90392580 100644
--- a/app/src/main/java/com/saulhdev/feeder/ui/components/StringSetPreference.kt
+++ b/app/src/main/java/com/saulhdev/feeder/ui/components/StringSetPreference.kt
@@ -18,9 +18,11 @@
package com.saulhdev.feeder.ui.components
+import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.saulhdev.feeder.data.content.StringSetPref
+import com.saulhdev.feeder.ui.navigation.LocalNavController
@Composable
fun StringSetPreference(
@@ -30,12 +32,24 @@ fun StringSetPreference(
groupSize: Int = 1,
isEnabled: Boolean = true,
) {
+ val navController = LocalNavController.current
BasePreference(
modifier = modifier,
titleId = pref.titleId,
summaryId = pref.summaryId,
index = index,
groupSize = groupSize,
- isEnabled = isEnabled
+ isEnabled = isEnabled,
+ startWidget = {
+ Icon(
+ imageVector = pref.icon,
+ contentDescription = null,
+ tint = androidx.compose.material3.MaterialTheme.colorScheme.onSurface,
+ )
+ },
+ onClick = {
+ pref.route?.let { navController.navigate(it) }
+ ?: pref.onClick?.invoke()
+ }
)
}
\ No newline at end of file
diff --git a/app/src/main/java/com/saulhdev/feeder/ui/navigation/NavigationManager.kt b/app/src/main/java/com/saulhdev/feeder/ui/navigation/NavigationManager.kt
index e9b625da..746add58 100644
--- a/app/src/main/java/com/saulhdev/feeder/ui/navigation/NavigationManager.kt
+++ b/app/src/main/java/com/saulhdev/feeder/ui/navigation/NavigationManager.kt
@@ -42,8 +42,11 @@ import com.saulhdev.feeder.ui.pages.AboutPage
import com.saulhdev.feeder.ui.pages.ArticleListPage
import com.saulhdev.feeder.ui.pages.ArticlePage
import com.saulhdev.feeder.ui.pages.ChangelogPage
+import com.saulhdev.feeder.ui.pages.BlockedWordsPage
import com.saulhdev.feeder.ui.pages.LicensePage
import com.saulhdev.feeder.ui.pages.MainPage
+import com.saulhdev.feeder.ui.pages.MastodonAddPage
+import com.saulhdev.feeder.ui.pages.MastodonCallbackPage
import com.saulhdev.feeder.ui.pages.PreferencesPage
import com.saulhdev.feeder.ui.pages.SourceAddPage
import com.saulhdev.feeder.ui.pages.SourceListPage
@@ -84,6 +87,18 @@ fun NavigationManager(
composable { LicensePage() }
composable { ChangelogPage() }
composable { SourceAddPage() }
+ composable { BlockedWordsPage() }
+ composable { MastodonAddPage() }
+ composable(
+ deepLinks = listOf(
+ navDeepLink {
+ uriPattern = "nf-mastodon://callback?code={code}&state={state}"
+ }
+ )
+ ) {
+ val args = it.toRoute()
+ MastodonCallbackPage(args.code, args.state)
+ }
composable(
deepLinks = listOf(navDeepLink { uriPattern = "$NAV_BASE${Routes.WEB_VIEW}/{url}" })
) {
@@ -138,6 +153,15 @@ open class NavRoute {
@Serializable
data object SourceAdd : NavRoute()
+ @Serializable
+ data object BlockedWords : NavRoute()
+
+ @Serializable
+ data object MastodonAdd : NavRoute()
+
+ @Serializable
+ data class MastodonCallback(val code: String = "", val state: String = "") : NavRoute()
+
@Serializable
data class ArticleView(val uuid: String = "") : NavRoute()
diff --git a/app/src/main/java/com/saulhdev/feeder/ui/pages/BlockedWordsPage.kt b/app/src/main/java/com/saulhdev/feeder/ui/pages/BlockedWordsPage.kt
new file mode 100644
index 00000000..ddb65935
--- /dev/null
+++ b/app/src/main/java/com/saulhdev/feeder/ui/pages/BlockedWordsPage.kt
@@ -0,0 +1,181 @@
+/*
+ * This file is part of Neo Feed
+ * Copyright (c) 2025 Neo Feed Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.saulhdev.feeder.ui.pages
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Button
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import com.saulhdev.feeder.R
+import com.saulhdev.feeder.data.content.FeedPreferences
+import com.saulhdev.feeder.data.repository.ArticleRepository
+import com.saulhdev.feeder.ui.components.ViewWithActionBar
+import com.saulhdev.feeder.ui.icons.Phosphor
+import com.saulhdev.feeder.ui.icons.phosphor.TrashSimple
+import com.saulhdev.feeder.ui.navigation.LocalNavController
+import kotlinx.coroutines.launch
+import org.koin.compose.koinInject
+
+@Composable
+fun BlockedWordsPage(
+ prefs: FeedPreferences = koinInject(),
+ articleRepository: ArticleRepository = koinInject(),
+) {
+ val navController = LocalNavController.current
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val focusManager = LocalFocusManager.current
+ val words by prefs.blockedWords.get().collectAsState(initial = emptySet())
+ var newWord by remember { mutableStateOf("") }
+
+ fun save(updated: Set) {
+ scope.launch {
+ prefs.blockedWords.setValue(updated)
+ articleRepository.deleteArticlesMatchingWords(updated, context.filesDir)
+ }
+ }
+
+ ViewWithActionBar(
+ title = stringResource(id = R.string.blocked_words_title),
+ onBackAction = { navController.popBackStack() }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(
+ start = 16.dp,
+ end = 16.dp,
+ top = paddingValues.calculateTopPadding(),
+ bottom = paddingValues.calculateBottomPadding()
+ ),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ OutlinedTextField(
+ value = newWord,
+ onValueChange = { newWord = it },
+ modifier = Modifier.weight(1f),
+ singleLine = true,
+ label = { Text(text = stringResource(id = R.string.blocked_words_hint)) },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ val trimmed = newWord.trim().lowercase()
+ if (trimmed.isNotBlank()) {
+ save(words + trimmed)
+ newWord = ""
+ focusManager.clearFocus()
+ }
+ }
+ )
+ )
+ Button(
+ onClick = {
+ val trimmed = newWord.trim().lowercase()
+ if (trimmed.isNotBlank()) {
+ save(words + trimmed)
+ newWord = ""
+ focusManager.clearFocus()
+ }
+ },
+ enabled = newWord.trim().isNotBlank()
+ ) {
+ Text(text = stringResource(id = R.string.blocked_words_add))
+ }
+ }
+
+ HorizontalDivider()
+
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(bottom = 16.dp)
+ ) {
+ if (words.isEmpty()) {
+ item {
+ Text(
+ text = stringResource(id = R.string.blocked_words_empty),
+ modifier = Modifier.padding(top = 16.dp),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ } else {
+ items(words.sorted(), key = { it }) { word ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = word,
+ style = MaterialTheme.typography.bodyLarge
+ )
+ IconButton(
+ onClick = { save(words - word) }
+ ) {
+ Icon(
+ imageVector = Phosphor.TrashSimple,
+ contentDescription = stringResource(id = R.string.action_delete)
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/saulhdev/feeder/ui/pages/MastodonAddPage.kt b/app/src/main/java/com/saulhdev/feeder/ui/pages/MastodonAddPage.kt
new file mode 100644
index 00000000..23f61042
--- /dev/null
+++ b/app/src/main/java/com/saulhdev/feeder/ui/pages/MastodonAddPage.kt
@@ -0,0 +1,134 @@
+/*
+ * This file is part of Neo Feed
+ * Copyright (c) 2025 Neo Feed Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.saulhdev.feeder.ui.pages
+
+import android.content.Intent
+import android.net.Uri
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import com.saulhdev.feeder.R
+import com.saulhdev.feeder.ui.components.ViewWithActionBar
+import com.saulhdev.feeder.ui.navigation.LocalNavController
+import com.saulhdev.feeder.utils.extensions.koinNeoViewModel
+import com.saulhdev.feeder.viewmodels.MastodonAuthViewModel
+
+@Composable
+fun MastodonAddPage(
+ viewModel: MastodonAuthViewModel = koinNeoViewModel(),
+) {
+ val context = LocalContext.current
+ val navController = LocalNavController.current
+ val uiState by viewModel.uiState.collectAsState()
+ var instance by rememberSaveable { mutableStateOf("") }
+
+ LaunchedEffect(uiState.launchUrl) {
+ uiState.launchUrl?.let { url ->
+ try {
+ CustomTabsIntent.Builder()
+ .build()
+ .launchUrl(context, url.toUri())
+ } catch (_: Exception) {
+ context.startActivity(
+ Intent(Intent.ACTION_VIEW, url.toUri()).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ )
+ }
+ viewModel.onLaunchHandled()
+ }
+ }
+
+ ViewWithActionBar(
+ title = stringResource(id = R.string.add_mastodon_account),
+ onBackAction = { navController.popBackStack() }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(
+ start = 16.dp,
+ end = 16.dp,
+ top = paddingValues.calculateTopPadding(),
+ bottom = paddingValues.calculateBottomPadding()
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Spacer(modifier = Modifier.height(16.dp))
+
+ OutlinedTextField(
+ value = instance,
+ onValueChange = { instance = it },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ label = { Text(text = stringResource(id = R.string.mastodon_instance_hint)) },
+ enabled = !uiState.isLoading
+ )
+
+ Button(
+ onClick = { viewModel.startAuth(instance) },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = instance.isNotBlank() && !uiState.isLoading
+ ) {
+ Text(text = stringResource(id = R.string.mastodon_connect))
+ }
+
+ if (uiState.isLoading) {
+ CircularProgressIndicator()
+ }
+
+ uiState.error?.let { error ->
+ Text(
+ text = stringResource(id = R.string.mastodon_auth_error, error),
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+}
diff --git a/app/src/main/java/com/saulhdev/feeder/ui/pages/MastodonCallbackPage.kt b/app/src/main/java/com/saulhdev/feeder/ui/pages/MastodonCallbackPage.kt
new file mode 100644
index 00000000..819bcc22
--- /dev/null
+++ b/app/src/main/java/com/saulhdev/feeder/ui/pages/MastodonCallbackPage.kt
@@ -0,0 +1,112 @@
+/*
+ * This file is part of Neo Feed
+ * Copyright (c) 2025 Neo Feed Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.saulhdev.feeder.ui.pages
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.saulhdev.feeder.R
+import com.saulhdev.feeder.ui.components.ViewWithActionBar
+import com.saulhdev.feeder.ui.navigation.LocalNavController
+import com.saulhdev.feeder.ui.navigation.NavRoute
+import com.saulhdev.feeder.utils.extensions.koinNeoViewModel
+import com.saulhdev.feeder.viewmodels.MastodonAuthViewModel
+
+@Composable
+fun MastodonCallbackPage(
+ code: String,
+ state: String,
+ viewModel: MastodonAuthViewModel = koinNeoViewModel(),
+) {
+ val navController = LocalNavController.current
+ val uiState by viewModel.uiState.collectAsState()
+ val completed by viewModel.completed.collectAsState()
+
+ LaunchedEffect(Unit) {
+ viewModel.completeAuth(code, state)
+ }
+
+ LaunchedEffect(completed) {
+ if (completed) {
+ navController.navigate(NavRoute.Main(page = 2)) {
+ launchSingleTop = true
+ }
+ }
+ }
+
+ ViewWithActionBar(
+ title = stringResource(id = R.string.add_mastodon_account),
+ showBackButton = false
+ ) { paddingValues ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ when {
+ uiState.isLoading -> CircularProgressIndicator()
+ completed -> {
+ Text(
+ text = stringResource(id = R.string.mastodon_account_added),
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+
+ uiState.error != null -> {
+ Text(
+ text = stringResource(id = R.string.mastodon_auth_error, uiState.error!!),
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Button(
+ onClick = { navController.popBackStack() },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(text = stringResource(id = R.string.go_back))
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/saulhdev/feeder/ui/pages/PreferencesPage.kt b/app/src/main/java/com/saulhdev/feeder/ui/pages/PreferencesPage.kt
index 30f2dc12..2d638ada 100644
--- a/app/src/main/java/com/saulhdev/feeder/ui/pages/PreferencesPage.kt
+++ b/app/src/main/java/com/saulhdev/feeder/ui/pages/PreferencesPage.kt
@@ -59,6 +59,7 @@ fun PreferencesPage(
val servicePrefs = listOf(
prefs.itemsPerFeed,
+ prefs.mastodonItemsPerFeed,
prefs.syncFrequency,
prefs.syncRange,
prefs.syncOnlyOnWifi,
@@ -66,6 +67,9 @@ fun PreferencesPage(
prefs.offlineReader,
prefs.removeDuplicates,
)
+ val filterPrefs = listOf(
+ prefs.blockedWords,
+ )
val themePrefs = listOf(
prefs.dynamicColor,
prefs.overlayTheme,
@@ -102,6 +106,13 @@ fun PreferencesPage(
onPrefDialog = onPrefDialog
)
}
+ item(key = R.string.pref_cat_filters) {
+ PreferenceGroup(
+ stringResource(id = R.string.pref_cat_filters),
+ prefs = filterPrefs,
+ onPrefDialog = onPrefDialog
+ )
+ }
item(key = R.string.pref_cat_overlay) {
PreferenceGroup(
stringResource(id = R.string.pref_cat_overlay),
diff --git a/app/src/main/java/com/saulhdev/feeder/ui/pages/SourceEditPage.kt b/app/src/main/java/com/saulhdev/feeder/ui/pages/SourceEditPage.kt
index a9bc7512..a88a527e 100644
--- a/app/src/main/java/com/saulhdev/feeder/ui/pages/SourceEditPage.kt
+++ b/app/src/main/java/com/saulhdev/feeder/ui/pages/SourceEditPage.kt
@@ -40,6 +40,8 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
@@ -57,6 +59,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.saulhdev.feeder.R
+import kotlinx.coroutines.launch
import com.saulhdev.feeder.data.entity.SourceEditViewState
import com.saulhdev.feeder.ui.components.ActionButton
import com.saulhdev.feeder.ui.components.ComposeSwitchView
@@ -70,6 +73,7 @@ import com.saulhdev.feeder.utils.extensions.interceptKey
import com.saulhdev.feeder.utils.extensions.koinNeoViewModel
import com.saulhdev.feeder.viewmodels.SourceEditViewModel
+
@Composable
fun SourceEditPage(
feedId: Long = -1,
@@ -78,13 +82,41 @@ fun SourceEditPage(
) {
val title = stringResource(id = R.string.edit_rss)
val viewState by viewModel.viewState.collectAsState()
- val editState = remember(viewState) {
+ // Initialise once per feed and do not overwrite user edits when viewState re-emits.
+ val editState = remember(feedId) {
mutableStateOf(viewState)
}
+ var hasLoaded by remember { mutableStateOf(false) }
+ var hasEdited by remember { mutableStateOf(false) }
val showDialog = remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
LaunchedEffect(feedId) {
viewModel.setFeedId(feedId)
+ if (!hasEdited && feedId != -1L) {
+ val freshFeed = viewModel.loadFeed(feedId)
+ if (freshFeed != null) {
+ editState.value = SourceEditViewState(
+ title = freshFeed.title,
+ url = freshFeed.url.toString(),
+ tag = freshFeed.tag,
+ fullTextByDefault = freshFeed.fullTextByDefault,
+ isEnabled = freshFeed.isEnabled,
+ sourceType = freshFeed.sourceType,
+ requireLink = freshFeed.requireLink,
+ requireImage = freshFeed.requireImage,
+ excludeReplies = freshFeed.excludeReplies,
+ )
+ hasLoaded = true
+ }
+ }
+ }
+
+ LaunchedEffect(viewState) {
+ if (!hasLoaded && !hasEdited && viewState.url.isNotBlank()) {
+ editState.value = viewState
+ hasLoaded = true
+ }
}
ViewWithActionBar(
@@ -113,8 +145,10 @@ fun SourceEditPage(
modifier = Modifier.weight(1f),
positive = true,
) {
- viewModel.updateFeed(editState.value)
- onDismiss()
+ scope.launch {
+ viewModel.updateFeed(editState.value)
+ onDismiss()
+ }
}
}
}
@@ -130,7 +164,8 @@ fun SourceEditPage(
horizontalAlignment = Alignment.CenterHorizontally
) {
SourceEditView(
- editState = editState
+ editState = editState,
+ onEdited = { hasEdited = true }
)
}
}
@@ -163,6 +198,7 @@ fun SourceEditPage(
@Composable
fun SourceEditView(
editState: MutableState,
+ onEdited: () -> Unit = {},
) {
val (focusTitle, focusTag) = createRefs()
val focusManager = LocalFocusManager.current
@@ -175,6 +211,7 @@ fun SourceEditView(
value = editState.value.url,
onValueChange = {
editState.value = editState.value.copy(url = it)
+ onEdited()
},
label = {
Text(stringResource(id = R.string.add_input_hint))
@@ -208,6 +245,7 @@ fun SourceEditView(
value = editState.value.title,
onValueChange = {
editState.value = editState.value.copy(title = it)
+ onEdited()
},
label = {
Text(stringResource(id = R.string.title))
@@ -242,6 +280,7 @@ fun SourceEditView(
value = editState.value.tag,
onValueChange = {
editState.value = editState.value.copy(tag = it)
+ onEdited()
},
label = {
Text(stringResource(id = R.string.source_tags))
@@ -278,9 +317,10 @@ fun SourceEditView(
isChecked = editState.value.fullTextByDefault,
onCheckedChange = {
editState.value = editState.value.copy(fullTextByDefault = it)
+ onEdited()
},
index = 0,
- groupSize = 2
+ groupSize = if (editState.value.sourceType == "mastodon") 5 else 2
)
Spacer(modifier = Modifier.height(4.dp))
ComposeSwitchView(
@@ -288,10 +328,46 @@ fun SourceEditView(
isChecked = editState.value.isEnabled,
onCheckedChange = {
editState.value = editState.value.copy(isEnabled = it)
+ onEdited()
},
index = 1,
- groupSize = 2
+ groupSize = if (editState.value.sourceType == "mastodon") 5 else 2
)
+ if (editState.value.sourceType == "mastodon") {
+ Spacer(modifier = Modifier.height(4.dp))
+ ComposeSwitchView(
+ titleId = R.string.mastodon_exclude_replies,
+ isChecked = editState.value.excludeReplies,
+ onCheckedChange = {
+ editState.value = editState.value.copy(excludeReplies = it)
+ onEdited()
+ },
+ index = 2,
+ groupSize = 5
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ ComposeSwitchView(
+ titleId = R.string.mastodon_require_link,
+ isChecked = editState.value.requireLink,
+ onCheckedChange = {
+ editState.value = editState.value.copy(requireLink = it)
+ onEdited()
+ },
+ index = 3,
+ groupSize = 5
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ ComposeSwitchView(
+ titleId = R.string.mastodon_require_image,
+ isChecked = editState.value.requireImage,
+ onCheckedChange = {
+ editState.value = editState.value.copy(requireImage = it)
+ onEdited()
+ },
+ index = 4,
+ groupSize = 5
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/saulhdev/feeder/ui/pages/SourceListPage.kt b/app/src/main/java/com/saulhdev/feeder/ui/pages/SourceListPage.kt
index af15ddd0..0af6b5c2 100644
--- a/app/src/main/java/com/saulhdev/feeder/ui/pages/SourceListPage.kt
+++ b/app/src/main/java/com/saulhdev/feeder/ui/pages/SourceListPage.kt
@@ -62,6 +62,7 @@ import com.saulhdev.feeder.ui.icons.phosphor.BookBookmark
import com.saulhdev.feeder.ui.icons.phosphor.Bookmarks
import com.saulhdev.feeder.ui.icons.phosphor.CloudArrowDown
import com.saulhdev.feeder.ui.icons.phosphor.CloudArrowUp
+import com.saulhdev.feeder.ui.icons.phosphor.Hash
import com.saulhdev.feeder.ui.icons.phosphor.Plus
import com.saulhdev.feeder.ui.navigation.LocalNavController
import com.saulhdev.feeder.ui.navigation.NavRoute
@@ -166,6 +167,19 @@ fun SourceListPage(
},
actions = {
OverflowMenu {
+ DropdownMenuItem(
+ leadingIcon = {
+ Icon(
+ Phosphor.Hash,
+ contentDescription = stringResource(id = R.string.add_mastodon_account),
+ )
+ },
+ onClick = {
+ hideMenu()
+ navController.navigate(NavRoute.MastodonAdd)
+ },
+ text = { Text(text = stringResource(id = R.string.add_mastodon_account)) }
+ )
DropdownMenuItem(
leadingIcon = {
Icon(
diff --git a/app/src/main/java/com/saulhdev/feeder/utils/FeederUtils.kt b/app/src/main/java/com/saulhdev/feeder/utils/FeederUtils.kt
index b6cb94e9..b6f92dd3 100644
--- a/app/src/main/java/com/saulhdev/feeder/utils/FeederUtils.kt
+++ b/app/src/main/java/com/saulhdev/feeder/utils/FeederUtils.kt
@@ -94,6 +94,18 @@ fun getItemsPerFeed(): Map {
)
}
+fun getMastodonItemsPerFeed(): Map {
+ return mapOf(
+ "5" to "5",
+ "10" to "10",
+ "20" to "20",
+ "40" to "40",
+ "60" to "60",
+ "80" to "80",
+ "100" to "100"
+ )
+}
+
fun getBackgroundOptions(context: Context): Map {
return mapOf(
"theme" to context.resources.getString(R.string.background_theme_option),
diff --git a/app/src/main/java/com/saulhdev/feeder/viewmodels/MastodonAuthViewModel.kt b/app/src/main/java/com/saulhdev/feeder/viewmodels/MastodonAuthViewModel.kt
new file mode 100644
index 00000000..67291b36
--- /dev/null
+++ b/app/src/main/java/com/saulhdev/feeder/viewmodels/MastodonAuthViewModel.kt
@@ -0,0 +1,154 @@
+/*
+ * This file is part of Neo Feed
+ * Copyright (c) 2025 Neo Feed Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.saulhdev.feeder.viewmodels
+
+import androidx.lifecycle.viewModelScope
+import com.saulhdev.feeder.data.db.models.Feed
+import com.saulhdev.feeder.data.repository.SourcesRepository
+import com.saulhdev.feeder.manager.mastodon.MASTODON_REDIRECT_URI
+import com.saulhdev.feeder.manager.mastodon.MastodonApi
+import com.saulhdev.feeder.manager.mastodon.MastodonAuth
+import com.saulhdev.feeder.manager.mastodon.MastodonStorage
+import com.saulhdev.feeder.manager.mastodon.buildMastodonFeedUrl
+import com.saulhdev.feeder.utils.extensions.NeoViewModel
+import com.saulhdev.feeder.utils.sloppyLinkToStrictURL
+import com.saulhdev.feeder.utils.sloppyLinkToStrictURLNoThrows
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import java.util.UUID
+
+class MastodonAuthViewModel(
+ private val storage: MastodonStorage,
+ private val auth: MastodonAuth,
+ private val api: MastodonApi,
+ private val sourcesRepo: SourcesRepository,
+) : NeoViewModel() {
+
+ data class UiState(
+ val isLoading: Boolean = false,
+ val error: String? = null,
+ val launchUrl: String? = null,
+ )
+
+ private val _uiState = MutableStateFlow(UiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _completed = MutableStateFlow(false)
+ val completed: StateFlow = _completed.asStateFlow()
+
+ fun onLaunchHandled() {
+ _uiState.update { it.copy(launchUrl = null) }
+ }
+
+ fun dismissError() {
+ _uiState.update { it.copy(error = null) }
+ }
+
+ fun startAuth(instanceInput: String) {
+ val instance = normalizeInstance(instanceInput)
+ if (instance.isBlank()) {
+ _uiState.update { it.copy(error = "Instance cannot be empty") }
+ return
+ }
+
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, error = null, launchUrl = null) }
+ auth.registerApp(instance)
+ .onSuccess { credentials ->
+ storage.saveAppCredentials(instance, credentials.clientId, credentials.clientSecret)
+ val state = UUID.randomUUID().toString()
+ storage.savePendingState(state, instance)
+ val url = auth.buildAuthorizationUrl(
+ instance = instance,
+ clientId = credentials.clientId,
+ redirectUri = MASTODON_REDIRECT_URI,
+ state = state,
+ )
+ _uiState.update { it.copy(isLoading = false, launchUrl = url) }
+ }
+ .onFailure { error ->
+ _uiState.update { it.copy(isLoading = false, error = error.message) }
+ }
+ }
+ }
+
+ fun completeAuth(code: String, state: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, error = null) }
+
+ val instance = storage.getPendingInstance(state)
+ if (instance == null) {
+ _uiState.update { it.copy(isLoading = false, error = "Invalid or expired authorization state") }
+ return@launch
+ }
+ storage.clearPendingState()
+
+ val (clientId, clientSecret) = storage.getAppCredentials(instance)
+ ?: run {
+ _uiState.update { it.copy(isLoading = false, error = "App credentials not found") }
+ return@launch
+ }
+
+ auth.exchangeCode(
+ instance = instance,
+ clientId = clientId,
+ clientSecret = clientSecret,
+ code = code,
+ redirectUri = MASTODON_REDIRECT_URI,
+ )
+ .onSuccess { token ->
+ api.verifyCredentials(instance, token)
+ .onSuccess { account ->
+ val username = account.acct.substringBefore("@").removePrefix("@")
+ storage.saveAccessToken(instance, username, token)
+ val feed = Feed(
+ title = "$username@$instance",
+ description = "Mastodon home timeline",
+ url = sloppyLinkToStrictURL(buildMastodonFeedUrl(instance, username)),
+ feedImage = sloppyLinkToStrictURLNoThrows(""),
+ sourceType = "mastodon",
+ requireLink = true,
+ requireImage = true,
+ excludeReplies = true,
+ )
+ sourcesRepo.insertSource(feed)
+ _uiState.update { it.copy(isLoading = false) }
+ _completed.value = true
+ }
+ .onFailure { error ->
+ _uiState.update { it.copy(isLoading = false, error = error.message) }
+ }
+ }
+ .onFailure { error ->
+ _uiState.update { it.copy(isLoading = false, error = error.message) }
+ }
+ }
+ }
+
+ private fun normalizeInstance(input: String): String {
+ return input.trim().lowercase()
+ .removePrefix("https://")
+ .removePrefix("http://")
+ .substringBefore("/")
+ .substringBefore("?")
+ }
+}
diff --git a/app/src/main/java/com/saulhdev/feeder/viewmodels/SourceEditViewModel.kt b/app/src/main/java/com/saulhdev/feeder/viewmodels/SourceEditViewModel.kt
index d5c07c7d..ecae47eb 100644
--- a/app/src/main/java/com/saulhdev/feeder/viewmodels/SourceEditViewModel.kt
+++ b/app/src/main/java/com/saulhdev/feeder/viewmodels/SourceEditViewModel.kt
@@ -21,29 +21,33 @@ package com.saulhdev.feeder.viewmodels
import androidx.lifecycle.viewModelScope
import com.saulhdev.feeder.data.db.models.Feed
import com.saulhdev.feeder.data.entity.SourceEditViewState
+import com.saulhdev.feeder.data.repository.ArticleRepository
import com.saulhdev.feeder.data.repository.SourcesRepository
+import com.saulhdev.feeder.manager.sync.requestFeedSync
import com.saulhdev.feeder.utils.extensions.NeoViewModel
import com.saulhdev.feeder.utils.sloppyLinkToStrictURL
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent.inject
@OptIn(ExperimentalCoroutinesApi::class)
class SourceEditViewModel : NeoViewModel() {
private val repository: SourcesRepository by inject(SourcesRepository::class.java)
+ private val articleRepository: ArticleRepository by inject(ArticleRepository::class.java)
- private val _feedId: MutableStateFlow = MutableStateFlow(-1L)
+ private val _feedId: MutableSharedFlow = MutableSharedFlow(replay = 1)
fun setFeedId(value: Long) {
- _feedId.update { value }
+ _feedId.tryEmit(value)
}
+ suspend fun loadFeed(feedId: Long): Feed? = repository.loadFeedById(feedId)
+
private val feed = _feedId.mapLatest {
repository.loadFeedById(it) ?: Feed()
}.stateIn(
@@ -52,18 +56,35 @@ class SourceEditViewModel : NeoViewModel() {
Feed()
)
- fun updateFeed(state: SourceEditViewState) {
+ suspend fun updateFeed(state: SourceEditViewState) {
+ val feedId = _feedId.replayCache.firstOrNull() ?: -1L
+ val currentFeed = repository.loadFeedById(feedId) ?: return
+ val filtersChanged = currentFeed.sourceType == "mastodon" &&
+ (currentFeed.requireLink != state.requireLink
+ || currentFeed.requireImage != state.requireImage
+ || currentFeed.excludeReplies != state.excludeReplies)
+ val needsResync = currentFeed.fullTextByDefault != state.fullTextByDefault
+ || currentFeed.isEnabled != state.isEnabled
+ || filtersChanged
+
repository.updateSource(
- feed = feed.value.copy(
+ feed = currentFeed.copy(
title = state.title,
url = sloppyLinkToStrictURL(state.url),
tag = state.tag,
fullTextByDefault = state.fullTextByDefault,
isEnabled = state.isEnabled,
+ requireLink = state.requireLink,
+ requireImage = state.requireImage,
+ excludeReplies = state.excludeReplies,
),
- resync = feed.value.fullTextByDefault != state.fullTextByDefault
- || feed.value.isEnabled != state.isEnabled
+ resync = needsResync
)
+
+ if (filtersChanged) {
+ articleRepository.deleteArticlesForFeed(currentFeed.id)
+ requestFeedSync(feedId = currentFeed.id, forceNetwork = true)
+ }
}
fun deleteFeed(feedId: Long) {
@@ -78,7 +99,11 @@ class SourceEditViewModel : NeoViewModel() {
url = feed.url.toString(),
tag = feed.tag,
fullTextByDefault = feed.fullTextByDefault,
- isEnabled = feed.isEnabled
+ isEnabled = feed.isEnabled,
+ sourceType = feed.sourceType,
+ requireLink = feed.requireLink,
+ requireImage = feed.requireImage,
+ excludeReplies = feed.excludeReplies,
)
}.stateIn(
viewModelScope,
diff --git a/app/src/main/res/layout/base_bottom_sheet.xml b/app/src/main/res/layout/base_bottom_sheet.xml
index 8eac29d8..aa7eb44e 100644
--- a/app/src/main/res/layout/base_bottom_sheet.xml
+++ b/app/src/main/res/layout/base_bottom_sheet.xml
@@ -19,7 +19,7 @@
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_gravity="bottom"
- android:background="?attr/bottomSheetNavBarColor"
+ android:background="#66000000"
android:focusable="false" />
\ No newline at end of file
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 07ca11fd..748f2e62 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -106,4 +106,12 @@
Afficher les marque-pages
Cacher les marque-pages
Marque-pages
+ Ajouter un compte Mastodon
+ Instance (p. ex. mastodon.social)
+ Connecter le compte
+ Ouverture de l’autorisation Mastodon…
+ Échec de l’autorisation Mastodon : %1$s
+ Compte Mastodon ajouté
+ Aucun jeton Mastodon trouvé pour ce compte
+ Nombre maximal d’éléments Mastodon par flux
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 94bf758d..957b4e0a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -43,6 +43,7 @@
Sync Frequency
Sync Range
Max items per feed
+ Max Mastodon items per feed
Syncing
Sync status
Compact item layout
@@ -163,4 +164,22 @@
Delete
Save
Open in SMRY
+
+ Add Mastodon account
+ Instance (e.g. mastodon.social)
+ Connect account
+ Opening Mastodon authorization…
+ Mastodon authorization failed: %1$s
+ Mastodon account added
+ Must contain a link
+ Must contain a picture
+ Hide replies
+ Filters
+ Blocked words
+ Hide articles containing these words
+ Blocked words
+ Add a word
+ Add
+ No blocked words
+ No Mastodon token found for this account
\ No newline at end of file
diff --git a/google-gsa/src/main/java/com/google/android/libraries/gsa/d/a/OverlayController.java b/google-gsa/src/main/java/com/google/android/libraries/gsa/d/a/OverlayController.java
index f5c25ffa..aa4d0065 100644
--- a/google-gsa/src/main/java/com/google/android/libraries/gsa/d/a/OverlayController.java
+++ b/google-gsa/src/main/java/com/google/android/libraries/gsa/d/a/OverlayController.java
@@ -147,9 +147,12 @@ public boolean isOpen() {
public void setVisible(boolean visible) {
if (visible) {
- window.clearFlags(24); // FLAG_NOT_TOUCHABLE | FLAG_NOT_FOCUSABLE
+ // Make the overlay touchable so the feed can be interacted with,
+ // but keep it non-focusable so the launcher/IME target never moves
+ // away from the launcher window.
+ window.clearFlags(16); // FLAG_NOT_TOUCHABLE
} else {
- window.addFlags(24);
+ window.addFlags(16); // FLAG_NOT_TOUCHABLE
}
}
diff --git a/google-gsa/src/main/java/com/google/android/libraries/gsa/d/a/OverlayControllerCallback.java b/google-gsa/src/main/java/com/google/android/libraries/gsa/d/a/OverlayControllerCallback.java
index 741203dd..c0158951 100644
--- a/google-gsa/src/main/java/com/google/android/libraries/gsa/d/a/OverlayControllerCallback.java
+++ b/google-gsa/src/main/java/com/google/android/libraries/gsa/d/a/OverlayControllerCallback.java
@@ -163,11 +163,18 @@ private void setupOverlayController(OverlayController controller, Bundle args, L
layoutParams.width = LayoutParams.MATCH_PARENT;
layoutParams.height = LayoutParams.MATCH_PARENT;
- layoutParams.flags |= 8650752;
+ // Start fully transparent, non-focusable and non-touchable so the overlay
+ // never briefly covers the launcher while it is being attached.
+ layoutParams.flags |= 8650752
+ | LayoutParams.FLAG_NOT_FOCUSABLE
+ | LayoutParams.FLAG_NOT_TOUCHABLE;
+ layoutParams.alpha = 0f;
layoutParams.dimAmount = 0f;
layoutParams.gravity = 3;
layoutParams.type = 4;
- layoutParams.softInputMode = LayoutParams.SOFT_INPUT_STATE_VISIBLE;
+ // Don't override the launcher window's soft-input behaviour; otherwise
+ // the app-drawer search box can't show its own keyboard.
+ layoutParams.softInputMode = LayoutParams.SOFT_INPUT_STATE_UNSPECIFIED;
controller.window.setAttributes(layoutParams);
controller.window.clearFlags(1048576);
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 8699455a..77e128a9 100755
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -25,6 +25,7 @@ readability4j = "1.0.8"
retrofit = "3.0.0"
rome = "2.1.0"
room = "2.8.3"
+security-crypto = "1.1.0"
serialization = "1.9.0"
simple-storage = "2.2.0"
slf4j = "1.7.36"
@@ -72,6 +73,8 @@ room-paging = { group = "androidx.room", name = "room-paging", version.ref = "ro
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
+security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" }
+
accompanist-webview = { group = "com.google.accompanist", name = "accompanist-webview", version.ref = "compose-accompanist" }
accompanist-systemuicontroller = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version.ref = "compose-accompanist" }