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" }