From bd377b403892d97a71e93522838782950cb15029 Mon Sep 17 00:00:00 2001 From: Aldo Gunsing Date: Thu, 1 Oct 2020 20:56:44 +0200 Subject: [PATCH 1/6] Add DecSync synchronization (#443) --- app/build.gradle | 7 +- .../net.frju.flym.data.AppDatabase/4.json | 301 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 17 +- .../java/net/frju/flym/data/AppDatabase.kt | 12 +- .../java/net/frju/flym/data/dao/EntryDao.kt | 135 +++++++- .../java/net/frju/flym/data/dao/FeedDao.kt | 129 +++++++- .../java/net/frju/flym/data/entities/Entry.kt | 45 ++- .../net/frju/flym/data/utils/PrefConstants.kt | 5 + .../flym/service/AutoRefreshJobService.kt | 1 + .../net/frju/flym/service/FetcherService.kt | 62 +++- .../frju/flym/ui/discover/DiscoverActivity.kt | 4 + .../frju/flym/ui/entries/EntriesFragment.kt | 12 + .../ui/entrydetails/EntryDetailsFragment.kt | 5 + .../flym/ui/feeds/FeedListEditFragment.kt | 3 + .../net/frju/flym/ui/main/MainActivity.kt | 59 ++-- .../frju/flym/ui/settings/SettingsFragment.kt | 100 +++++- .../java/net/frju/flym/utils/DecsyncUtils.kt | 210 ++++++++++++ app/src/main/res/values/strings.xml | 12 + app/src/main/res/xml/settings.xml | 23 ++ 19 files changed, 1099 insertions(+), 43 deletions(-) create mode 100644 app/schemas/net.frju.flym.data.AppDatabase/4.json create mode 100644 app/src/main/java/net/frju/flym/utils/DecsyncUtils.kt diff --git a/app/build.gradle b/app/build.gradle index 4aa4932d9..ba074452c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -8,11 +8,11 @@ androidExtensions { } android { - compileSdkVersion 30 + compileSdkVersion 29 defaultConfig { applicationId "net.frju.flym" minSdkVersion 21 - targetSdkVersion 30 + targetSdkVersion 29 // Allows for legacy storage on Android 11 versionCode 37 versionName "2.5.3" } @@ -122,4 +122,7 @@ dependencies { implementation 'net.dankito.readability4j:readability4j:1.0.5' implementation 'pub.devrel:easypermissions:3.0.0' implementation 'com.rometools:rome-opml:1.15.0' + implementation 'org.jetbrains.kotlinx:kotlinx-serialization-core:1.0.0-RC' + implementation 'org.decsync:libdecsync:1.7.1' + implementation 'com.nononsenseapps:filepicker:4.1.0' } diff --git a/app/schemas/net.frju.flym.data.AppDatabase/4.json b/app/schemas/net.frju.flym.data.AppDatabase/4.json new file mode 100644 index 000000000..0828d2ff5 --- /dev/null +++ b/app/schemas/net.frju.flym.data.AppDatabase/4.json @@ -0,0 +1,301 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "68944b920ee4a639a67bc8f29472e1b7", + "entities": [ + { + "tableName": "feeds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`feedId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `feedLink` TEXT NOT NULL, `feedTitle` TEXT, `feedImageLink` TEXT, `fetchError` INTEGER NOT NULL, `retrieveFullText` INTEGER NOT NULL, `isGroup` INTEGER NOT NULL, `groupId` INTEGER, `displayPriority` INTEGER NOT NULL, `lastManualActionUid` TEXT NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `feeds`(`feedId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "feedId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "feedLink", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "feedTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageLink", + "columnName": "feedImageLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fetchError", + "columnName": "fetchError", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "retrieveFullText", + "columnName": "retrieveFullText", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isGroup", + "columnName": "isGroup", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "displayPriority", + "columnName": "displayPriority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastManualActionUid", + "columnName": "lastManualActionUid", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "feedId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_feeds_groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feeds_groupId` ON `${TABLE_NAME}` (`groupId`)" + }, + { + "name": "index_feeds_feedId_feedLink", + "unique": true, + "columnNames": [ + "feedId", + "feedLink" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feeds_feedId_feedLink` ON `${TABLE_NAME}` (`feedId`, `feedLink`)" + } + ], + "foreignKeys": [ + { + "table": "feeds", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "groupId" + ], + "referencedColumns": [ + "feedId" + ] + } + ] + }, + { + "tableName": "entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `feedId` INTEGER NOT NULL, `link` TEXT, `uri` TEXT, `fetchDate` INTEGER NOT NULL, `publicationDate` INTEGER NOT NULL, `title` TEXT, `description` TEXT, `mobilizedContent` TEXT, `imageLink` TEXT, `author` TEXT, `read` INTEGER NOT NULL, `favorite` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`feedId`) REFERENCES `feeds`(`feedId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feedId", + "columnName": "feedId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fetchDate", + "columnName": "fetchDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicationDate", + "columnName": "publicationDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mobilizedContent", + "columnName": "mobilizedContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageLink", + "columnName": "imageLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_entries_feedId", + "unique": false, + "columnNames": [ + "feedId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_entries_feedId` ON `${TABLE_NAME}` (`feedId`)" + }, + { + "name": "index_entries_link", + "unique": true, + "columnNames": [ + "link" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_entries_link` ON `${TABLE_NAME}` (`link`)" + }, + { + "name": "index_entries_uri", + "unique": true, + "columnNames": [ + "uri" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_entries_uri` ON `${TABLE_NAME}` (`uri`)" + } + ], + "foreignKeys": [ + { + "table": "feeds", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "feedId" + ], + "referencedColumns": [ + "feedId" + ] + } + ] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`entryId` TEXT NOT NULL, `imageLinkToDl` TEXT NOT NULL, `numberAttempt` INTEGER NOT NULL, PRIMARY KEY(`entryId`, `imageLinkToDl`), FOREIGN KEY(`entryId`) REFERENCES `entries`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "entryId", + "columnName": "entryId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageLinkToDl", + "columnName": "imageLinkToDl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "numberAttempt", + "columnName": "numberAttempt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "entryId", + "imageLinkToDl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_tasks_entryId", + "unique": false, + "columnNames": [ + "entryId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_entryId` ON `${TABLE_NAME}` (`entryId`)" + } + ], + "foreignKeys": [ + { + "table": "entries", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "entryId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "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, '68944b920ee4a639a67bc8f29472e1b7')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2d575e647..16a355db5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,7 +14,8 @@ android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/AppTheme" - android:usesCleartextTraffic="true"> + android:usesCleartextTraffic="true" + android:requestLegacyExternalStorage="true"> + + + + + :minDate") abstract fun observeNewEntriesCountByGroup(groupId: Long, minDate: Long): LiveData + @get:Query("SELECT id FROM entries WHERE read = 1") + abstract val readIds: List + + @get:Query("SELECT id FROM entries WHERE read = 0") + abstract val unreadIds: List + @get:Query("SELECT id FROM entries WHERE favorite = 1") abstract val favoriteIds: List @@ -119,38 +131,145 @@ abstract class EntryDao { @Query("SELECT * FROM $JOIN WHERE id IS :id LIMIT 1") abstract fun findByIdWithFeed(id: String): EntryWithFeed? + @Query("SELECT id FROM entries WHERE link IS :link LIMIT 1") + abstract fun idForLink(link: String): String? + + @Query("SELECT id FROM entries WHERE uri IS :uri LIMIT 1") + abstract fun idForUri(uri: String): String? + @Query("SELECT title FROM entries WHERE title IN (:titles)") abstract fun findAlreadyExistingTitles(titles: List): List @Query("SELECT id FROM entries WHERE feedId IS (:feedId)") abstract fun idsForFeed(feedId: Long): List + @Query("SELECT id FROM entries WHERE feedId IS (:feedId) AND read = 0") + abstract fun unreadIdsForFeed(feedId: Long): List + + @Query("SELECT id FROM $JOIN WHERE groupId IS (:groupId) AND read = 0") + abstract fun unreadIdsForGroup(groupId: Long): List + @Query("UPDATE entries SET read = 1 WHERE id IN (:ids)") - abstract fun markAsRead(ids: List) + protected abstract fun markAsReadDao(ids: List) + + @ExperimentalStdlibApi + fun markAsRead(ids: List, updateDecsync: Boolean = true) { + if (updateDecsync && App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { + val entries = ids.mapNotNull { id -> + getReadMarkEntry(id, "read", true) + } + DecsyncUtils.getDecsync(App.context)?.setEntries(entries) + } + markAsReadDao(ids) + } @Query("UPDATE entries SET read = 0 WHERE id IN (:ids)") - abstract fun markAsUnread(ids: List) + protected abstract fun markAsUnreadDao(ids: List) + + @ExperimentalStdlibApi + fun markAsUnread(ids: List, updateDecsync: Boolean = true) { + if (updateDecsync && App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { + val entries = ids.mapNotNull { id -> + getReadMarkEntry(id, "read", false) + } + DecsyncUtils.getDecsync(App.context)?.setEntries(entries) + } + markAsUnreadDao(ids) + } @Query("UPDATE entries SET read = 1 WHERE feedId = :feedId") - abstract fun markAsRead(feedId: Long) + protected abstract fun markAsReadDao(feedId: Long) + + @ExperimentalStdlibApi + fun markAsRead(feedId: Long, updateDecsync: Boolean = true) { + if (updateDecsync && App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { + val entries = unreadIdsForFeed(feedId).mapNotNull { id -> + getReadMarkEntry(id, "read", true) + } + DecsyncUtils.getDecsync(App.context)?.setEntries(entries) + } + markAsReadDao(feedId) + } @Query("UPDATE entries SET read = 1 WHERE feedId IN (SELECT feedId FROM feeds WHERE groupId = :groupId)") - abstract fun markGroupAsRead(groupId: Long) + protected abstract fun markGroupAsReadDao(groupId: Long) + + @ExperimentalStdlibApi + fun markGroupAsRead(groupId: Long, updateDecsync: Boolean = true) { + if (updateDecsync && App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { + val entries = unreadIdsForGroup(groupId).mapNotNull { id -> + getReadMarkEntry(id, "read", true) + } + DecsyncUtils.getDecsync(App.context)?.setEntries(entries) + } + markGroupAsReadDao(groupId) + } @Query("UPDATE entries SET read = 1") - abstract fun markAllAsRead() + protected abstract fun markAllAsReadDao() + + @ExperimentalStdlibApi + fun markAllAsRead(updateDecsync: Boolean = true) { + if (updateDecsync && App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { + val entries = unreadIds.mapNotNull { id -> + getReadMarkEntry(id, "read", true) + } + DecsyncUtils.getDecsync(App.context)?.setEntries(entries) + } + markAllAsReadDao() + } @Query("UPDATE entries SET favorite = 1 WHERE id IS :id") - abstract fun markAsFavorite(id: String) + protected abstract fun markAsFavoriteDao(id: String) + + @ExperimentalStdlibApi + fun markAsFavorite(id: String, updateDecsync: Boolean = true) { + if (updateDecsync && App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { + writeReadMarkEntry(id, "marked", true) + } + markAsFavoriteDao(id) + } @Query("UPDATE entries SET favorite = 0 WHERE id IS :id") - abstract fun markAsNotFavorite(id: String) + protected abstract fun markAsNotFavoriteDao(id: String) + + @ExperimentalStdlibApi + fun markAsNotFavorite(id: String, updateDecsync: Boolean = true) { + if (updateDecsync && App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { + writeReadMarkEntry(id, "marked", false) + } + markAsNotFavoriteDao(id) + } + + @ExperimentalStdlibApi + fun getReadMarkEntry(id: String, type: String, value: Boolean): Decsync.EntryWithPath? { + val entry = findById(id) ?: return null + return entry.getDecsyncEntry(type, value) + } + + @ExperimentalStdlibApi + private fun writeReadMarkEntry(id: String, type: String, value: Boolean) { + val entry = getReadMarkEntry(id, type, value) ?: return + DecsyncUtils.getDecsync(App.context)?.setEntries(listOf(entry)) + } @Query("DELETE FROM entries WHERE fetchDate < :keepDateBorderTime AND favorite = 0 AND read = :read") abstract fun deleteOlderThan(keepDateBorderTime: Long, read: Long) @Insert(onConflict = OnConflictStrategy.IGNORE) // Ignore because we don't want to delete previously starred entries - abstract fun insert(vararg entries: Entry) + protected abstract fun insertDao(vararg entries: Entry) + + @ExperimentalStdlibApi + fun insert(entries: List) { + insertDao(*entries.toTypedArray()) + val storedEntries = mutableListOf() + for (entry in entries) { + storedEntries.add(entry.getDecsyncStoredEntry("read") ?: continue) + storedEntries.add(entry.getDecsyncStoredEntry("marked") ?: continue) + } + val extra = Extra() + DecsyncUtils.getDecsync(App.context)?.executeStoredEntries(storedEntries, extra) + } @Update abstract fun update(vararg entries: Entry) diff --git a/app/src/main/java/net/frju/flym/data/dao/FeedDao.kt b/app/src/main/java/net/frju/flym/data/dao/FeedDao.kt index 6c22399cd..042a7a493 100644 --- a/app/src/main/java/net/frju/flym/data/dao/FeedDao.kt +++ b/app/src/main/java/net/frju/flym/data/dao/FeedDao.kt @@ -17,6 +17,7 @@ package net.frju.flym.data.dao +import android.util.Log import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Delete @@ -24,10 +25,19 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update +import kotlinx.serialization.json.JsonPrimitive +import net.frju.flym.App import net.frju.flym.data.entities.Feed import net.frju.flym.data.entities.FeedWithCount +import net.frju.flym.data.utils.PrefConstants.DECSYNC_ENABLED +import net.frju.flym.utils.DecsyncUtils +import net.frju.flym.utils.Extra +import net.frju.flym.utils.getPrefBoolean +import org.decsync.library.Decsync +import java.util.* private const val ENTRY_COUNT = "(SELECT COUNT(*) FROM entries WHERE feedId IS f.feedId AND read = 0)" +private const val TAG = "FeedDao" @Dao abstract class FeedDao { @@ -52,9 +62,6 @@ abstract class FeedDao { @Query("SELECT * FROM feeds WHERE feedLink IS :link") abstract fun findByLink(link: String): Feed? - @Query("DELETE FROM feeds WHERE feedLink IS :link") - abstract fun deleteByLink(link: String) - @Query("UPDATE feeds SET retrieveFullText = 1 WHERE feedId = :feedId") abstract fun enableFullTextRetrieval(feedId: Long) @@ -62,11 +69,121 @@ abstract class FeedDao { abstract fun disableFullTextRetrieval(feedId: Long) @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract fun insert(vararg feeds: Feed) + protected abstract fun insertDao(vararg feeds: Feed): List + + @ExperimentalStdlibApi + fun insert(feeds: List, updateDecsync: Boolean = true): List { + for (feed in feeds) { + if (feed.isGroup && feed.link.isEmpty()) { + feed.link = "catID%05".format(Random().nextInt(100000)) + } + } + if (updateDecsync && App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { + val entries = mutableListOf() + for (feed in feeds) { + if (feed.isGroup) { + entries.add(Decsync.EntryWithPath(listOf("categories", "names"), JsonPrimitive(feed.link), JsonPrimitive(feed.title))) + } else { + entries.add(Decsync.EntryWithPath(listOf("feeds", "subscriptions"), JsonPrimitive(feed.link), JsonPrimitive(true))) + if (feed.title != null) { + entries.add(Decsync.EntryWithPath(listOf("feeds", "names"), JsonPrimitive(feed.link), JsonPrimitive(feed.title))) + } + feed.groupId?.let { findById(it) }?.let { group -> + entries.add(Decsync.EntryWithPath(listOf("feeds", "categories"), JsonPrimitive(feed.link), JsonPrimitive(group.link))) + } + } + } + DecsyncUtils.getDecsync(App.context)?.setEntries(entries) + } + val ids = insertDao(*feeds.toTypedArray()) + if (App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { + for (feed in feeds) { + val extra = Extra() + if (feed.isGroup) { + DecsyncUtils.getDecsync(App.context)?.executeStoredEntry(listOf("categories", "names"), JsonPrimitive(feed.link), extra) + } else { + DecsyncUtils.getDecsync(App.context)?.executeStoredEntry(listOf("feeds", "names"), JsonPrimitive(feed.link), extra) + DecsyncUtils.getDecsync(App.context)?.executeStoredEntry(listOf("feeds", "categories"), JsonPrimitive(feed.link), extra) + } + } + } + return ids + } + + @ExperimentalStdlibApi + fun insert(feed: Feed, updateDecsync: Boolean = true): Long { + val ids = insert(listOf(feed), updateDecsync) + if (ids.size != 1) { + Log.w(TAG, "Wrong insertion for feed $feed") + return 0 + } + return ids[0] + } @Update - abstract fun update(vararg feeds: Feed) + protected abstract fun updateDao(vararg feeds: Feed) + + @ExperimentalStdlibApi + fun update(feeds: List, updateDecsync: Boolean = true) { + if (updateDecsync && App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { + val entries = mutableListOf() + for (feed in feeds) { + val origFeed = findById(feed.id) ?: continue + if (feed.isGroup) { + if (origFeed.title != feed.title) { + entries.add(Decsync.EntryWithPath(listOf("categories", "names"), JsonPrimitive(feed.link), JsonPrimitive(feed.title))) + } + } else { + var newLink = false + if (origFeed.link != feed.link) { + entries.add(Decsync.EntryWithPath(listOf("feeds", "subscriptions"), JsonPrimitive(origFeed.link), JsonPrimitive(false))) + entries.add(Decsync.EntryWithPath(listOf("feeds", "subscriptions"), JsonPrimitive(feed.link), JsonPrimitive(true))) + newLink = true + } + if (newLink || origFeed.title != feed.title) { + entries.add(Decsync.EntryWithPath(listOf("feeds", "names"), JsonPrimitive(feed.link), JsonPrimitive(feed.title))) + } + if (newLink || origFeed.groupId != feed.groupId) { + val group = feed.groupId?.let { findById(it) } + entries.add(Decsync.EntryWithPath(listOf("feeds", "categories"), JsonPrimitive(feed.link), JsonPrimitive(group?.link))) + } + } + } + DecsyncUtils.getDecsync(App.context)?.setEntries(entries) + } + updateDao(*feeds.toTypedArray()) + } + + @ExperimentalStdlibApi + fun update(feed: Feed, updateDecsync: Boolean = true) = update(listOf(feed), updateDecsync) @Delete - abstract fun delete(vararg feeds: Feed) + protected abstract fun deleteDao(vararg feeds: Feed) + + @ExperimentalStdlibApi + fun delete(feeds: List, updateDecsync: Boolean = true) { + if (updateDecsync && App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { + val entries = feeds.mapNotNull { feed -> + if (feed.isGroup) return@mapNotNull null + Decsync.Entry(JsonPrimitive(feed.link), JsonPrimitive(false)) + } + DecsyncUtils.getDecsync(App.context)?.setEntriesForPath(listOf("feeds", "subscriptions"), entries) + } + deleteDao(*feeds.toTypedArray()) + } + + @ExperimentalStdlibApi + fun delete(feed: Feed, updateDecsync: Boolean = true) = delete(listOf(feed), updateDecsync) + + @ExperimentalStdlibApi + fun deleteById(id: Long, updateDecsync: Boolean = true) { + val feed = findById(id) ?: return + delete(feed, updateDecsync) + } + + @ExperimentalStdlibApi + fun deleteByLink(link: String, updateDecsync: Boolean = true) { + val feed = findByLink(link) ?: return + delete(feed, updateDecsync) + } } \ No newline at end of file diff --git a/app/src/main/java/net/frju/flym/data/entities/Entry.kt b/app/src/main/java/net/frju/flym/data/entities/Entry.kt index 30384c0a4..96b88bade 100644 --- a/app/src/main/java/net/frju/flym/data/entities/Entry.kt +++ b/app/src/main/java/net/frju/flym/data/entities/Entry.kt @@ -21,6 +21,7 @@ import android.content.Context import android.os.Parcelable import android.text.format.DateFormat import android.text.format.DateUtils +import android.util.Log import androidx.core.text.HtmlCompat import androidx.room.Entity import androidx.room.ForeignKey @@ -28,15 +29,17 @@ import androidx.room.Index import androidx.room.PrimaryKey import com.rometools.rome.feed.synd.SyndEntry import kotlinx.android.parcel.Parcelize +import kotlinx.serialization.json.JsonPrimitive import net.fred.feedex.R import net.frju.flym.utils.sha1 -import java.util.Date -import java.util.UUID +import org.decsync.library.Decsync +import java.util.* +private const val TAG = "Entry" @Parcelize @Entity(tableName = "entries", - indices = [(Index(value = ["feedId"])), (Index(value = ["link"], unique = true))], + indices = [(Index(value = ["feedId"])), (Index(value = ["link"], unique = true)), (Index(value = ["uri"], unique = true))], foreignKeys = [(ForeignKey(entity = Feed::class, parentColumns = ["feedId"], childColumns = ["feedId"], @@ -45,6 +48,7 @@ data class Entry(@PrimaryKey var id: String = "", var feedId: Long = 0L, var link: String? = null, + var uri: String? = null, var fetchDate: Date = Date(), var publicationDate: Date = fetchDate, // important to know if the publication date has been set var title: String? = null, @@ -62,6 +66,40 @@ data class Entry(@PrimaryKey DateFormat.getMediumDateFormat(context).format(publicationDate) + ' ' + DateFormat.getTimeFormat(context).format(publicationDate) } + + @ExperimentalStdlibApi + fun getDecsyncEntry(type: String, value: Boolean): Decsync.EntryWithPath? { + if (publicationDate == fetchDate) { + Log.w(TAG, "Unknown publication date for entry $this") + return null + } + val path = getDecsyncPath(type) + val key = uri ?: run { + Log.w(TAG, "Unknown uri for entry $this") + return null + } + return Decsync.EntryWithPath(path, JsonPrimitive(key), JsonPrimitive(value)) + } + + @ExperimentalStdlibApi + fun getDecsyncStoredEntry(type: String): Decsync.StoredEntry? { + val path = getDecsyncPath(type) + val key = uri ?: run { + Log.w(TAG, "Unknown uri for entry $this") + return null + } + return Decsync.StoredEntry(path, JsonPrimitive(key)) + } + + private fun getDecsyncPath(type: String): List { + val time = publicationDate.time + val date = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + date.timeInMillis = time + val year = "%04d".format(date.get(Calendar.YEAR)) + val month = "%02d".format(date.get(Calendar.MONTH) + 1) + val day = "%02d".format(date.get(Calendar.DAY_OF_MONTH)) + return listOf("articles", type, year, month, day) + } } fun SyndEntry.toDbFormat(context: Context, feed: Feed): Entry { @@ -76,6 +114,7 @@ fun SyndEntry.toDbFormat(context: Context, feed: Feed): Entry { } item.description = contents.getOrNull(0)?.value ?: description?.value item.link = link + item.uri = uri //TODO item.imageLink = null item.author = author diff --git a/app/src/main/java/net/frju/flym/data/utils/PrefConstants.kt b/app/src/main/java/net/frju/flym/data/utils/PrefConstants.kt index b85736284..c6b1aae8b 100644 --- a/app/src/main/java/net/frju/flym/data/utils/PrefConstants.kt +++ b/app/src/main/java/net/frju/flym/data/utils/PrefConstants.kt @@ -53,4 +53,9 @@ object PrefConstants { const val SORT_ORDER = "sort_order" const val ENABLE_SWIPE_ENTRY = "enable_swipe_entry" + + const val DECSYNC_ENABLED = "decsync.enabled"; + const val DECSYNC_USE_SAF = "decsync.use_saf"; + const val UPDATE_FORCES_SAF = "update_forces_saf" + const val DECSYNC_FILE = "decsync.directory"; } diff --git a/app/src/main/java/net/frju/flym/service/AutoRefreshJobService.kt b/app/src/main/java/net/frju/flym/service/AutoRefreshJobService.kt index 78c875ba4..10d9b4ced 100644 --- a/app/src/main/java/net/frju/flym/service/AutoRefreshJobService.kt +++ b/app/src/main/java/net/frju/flym/service/AutoRefreshJobService.kt @@ -64,6 +64,7 @@ class AutoRefreshJobService : JobService() { } } + @ExperimentalStdlibApi override fun onStartJob(params: JobParameters): Boolean { if (!ignoreNextJob && !getPrefBoolean(PrefConstants.IS_REFRESHING, false)) { doAsync { diff --git a/app/src/main/java/net/frju/flym/service/FetcherService.kt b/app/src/main/java/net/frju/flym/service/FetcherService.kt index 28bb87dd2..c83567231 100644 --- a/app/src/main/java/net/frju/flym/service/FetcherService.kt +++ b/app/src/main/java/net/frju/flym/service/FetcherService.kt @@ -33,6 +33,7 @@ import androidx.core.app.NotificationCompat import androidx.core.text.HtmlCompat import com.rometools.rome.io.SyndFeedInput import com.rometools.rome.io.XmlReader +import kotlinx.serialization.json.JsonPrimitive import net.dankito.readability4j.extended.Readability4JExtended import net.fred.feedex.R import net.frju.flym.App @@ -50,6 +51,7 @@ import okhttp3.OkHttpClient import okhttp3.Request import okio.buffer import okio.sink +import org.decsync.library.Decsync import org.jetbrains.anko.* import org.jsoup.Jsoup import java.io.File @@ -57,6 +59,7 @@ import java.io.FileOutputStream import java.io.IOException import java.net.CookieManager import java.net.CookiePolicy +import java.util.* import java.util.concurrent.ExecutorCompletionService import java.util.concurrent.Executors import java.util.concurrent.TimeUnit @@ -79,6 +82,7 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { .build() const val FROM_AUTO_REFRESH = "FROM_AUTO_REFRESH" + const val FROM_INIT_SYNC = "FROM_INIT_SYNC" const val ACTION_REFRESH_FEEDS = "net.frju.flym.REFRESH" const val ACTION_MOBILIZE_FEEDS = "net.frju.flym.MOBILIZE_FEEDS" @@ -98,7 +102,8 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { .addHeader("accept", "*/*") .build()) - fun fetch(context: Context, isFromAutoRefresh: Boolean, action: String, feedId: Long = 0L) { + @ExperimentalStdlibApi + fun fetch(context: Context, isFromAutoRefresh: Boolean, action: String, feedId: Long = 0L, isFromInitSync: Boolean = false) { if (context.getPrefBoolean(PrefConstants.IS_REFRESHING, false)) { return } @@ -137,6 +142,52 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { // We need to use the more recent date in order to be sure to not see old entries again val acceptMinDate = max(readEntriesKeepDate, unreadEntriesKeepDate) + if (context.getPrefBoolean(PrefConstants.DECSYNC_ENABLED, false)) { + val extra = Extra() + if (isFromInitSync) { + // Give old groups a category ID + val updatedGroups = App.db.feedDao().all.mapNotNull { feed -> + if (feed.isGroup && feed.link.isEmpty()) { + feed.link = "catID%05".format(Random().nextInt(100000)) + feed + } else { + null + } + } + App.db.feedDao().update(updatedGroups, false) + + // Initialize DecSync and subscribe to its feeds + DecsyncUtils.getDecsync(context)?.initStoredEntries() + DecsyncUtils.getDecsync(context)?.executeStoredEntriesForPathExact(listOf("feeds", "subscriptions"), extra) + + // Write subscriptions, categories and read and marked articles to DecSync + // It doesn't matter some are already there, as libdecsync will ignore these + val entries = mutableListOf() + for (feed in App.db.feedDao().all) { + if (feed.isGroup) { + entries.add(Decsync.EntryWithPath(listOf("categories", "names"), JsonPrimitive(feed.link), JsonPrimitive(feed.title))) + } else { + entries.add(Decsync.EntryWithPath(listOf("feeds", "subscriptions"), JsonPrimitive(feed.link), JsonPrimitive(true))) + if (feed.title != null) { + entries.add(Decsync.EntryWithPath(listOf("feeds", "names"), JsonPrimitive(feed.link), JsonPrimitive(feed.title))) + } + feed.groupId?.let { App.db.feedDao().findById(it) }?.let { group -> + entries.add(Decsync.EntryWithPath(listOf("feeds", "categories"), JsonPrimitive(feed.link), JsonPrimitive(group.link))) + } + } + } + for (id in App.db.entryDao().readIds) { + entries.add(App.db.entryDao().getReadMarkEntry(id, "read", true) ?: continue) + } + for (id in App.db.entryDao().favoriteIds) { + entries.add(App.db.entryDao().getReadMarkEntry(id, "marked", true) ?: continue) + } + DecsyncUtils.getDecsync(context)?.setEntries(entries) + } else { + DecsyncUtils.getDecsync(context)?.executeAllNewEntries(extra) + } + } + var newCount = 0 if (feedId == 0L || App.db.feedDao().findById(feedId)!!.isGroup) { newCount = refreshFeeds(feedId, acceptMinDate) @@ -355,6 +406,7 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { } } + @ExperimentalStdlibApi private fun refreshFeeds(feedId: Long, acceptMinDate: Long): Int { val executor = Executors.newFixedThreadPool(THREAD_NUMBER) { r -> @@ -398,6 +450,7 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { return globalResult } + @ExperimentalStdlibApi private fun refreshFeed(feed: Feed, acceptMinDate: Long): Int { val entries = mutableListOf() val entriesToInsert = mutableListOf() @@ -485,7 +538,7 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { } // Insert everything - App.db.entryDao().insert(*(entriesToInsert.toTypedArray())) + App.db.entryDao().insert(entriesToInsert) if (feed.retrieveFullText) { addEntriesToMobilize(entries.map { it.id }) @@ -562,6 +615,7 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { private val handler = Handler() + @ExperimentalStdlibApi public override fun onHandleIntent(intent: Intent?) { if (intent == null) { // No intent, we quit return @@ -578,6 +632,8 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { return } - fetch(this, isFromAutoRefresh, intent.action!!, intent.getLongExtra(EXTRA_FEED_ID, 0L)) + val feedId = intent.getLongExtra(EXTRA_FEED_ID, 0L) + val isFromInitSync = intent.getBooleanExtra(FROM_INIT_SYNC, false) + fetch(this, isFromAutoRefresh, intent.action!!, feedId, isFromInitSync) } } diff --git a/app/src/main/java/net/frju/flym/ui/discover/DiscoverActivity.kt b/app/src/main/java/net/frju/flym/ui/discover/DiscoverActivity.kt index 4b166565a..344718f83 100644 --- a/app/src/main/java/net/frju/flym/ui/discover/DiscoverActivity.kt +++ b/app/src/main/java/net/frju/flym/ui/discover/DiscoverActivity.kt @@ -39,6 +39,7 @@ class DiscoverActivity : AppCompatActivity(), FeedManagementInterface { private var searchInput: AutoCompleteTextView? = null + @ExperimentalStdlibApi override fun onCreate(savedInstanceState: Bundle?) { setupTheme() super.onCreate(savedInstanceState) @@ -58,6 +59,7 @@ class DiscoverActivity : AppCompatActivity(), FeedManagementInterface { savedInstanceState.putString(FeedSearchFragment.ARG_QUERY, searchInput?.text?.toString()) } + @ExperimentalStdlibApi private fun initSearchInputs() { var timer = Timer() searchInput = this.findViewById(R.id.et_search_input) @@ -133,6 +135,7 @@ class DiscoverActivity : AppCompatActivity(), FeedManagementInterface { searchInput?.setText(query) } + @ExperimentalStdlibApi override fun addFeed(view: View, title: String, link: String) { doAsync { val feedToAdd = Feed(link = link, title = title) @@ -143,6 +146,7 @@ class DiscoverActivity : AppCompatActivity(), FeedManagementInterface { } } + @ExperimentalStdlibApi override fun deleteFeed(view: View, feed: SearchFeedResult) { doAsync { App.db.feedDao().deleteByLink(feed.link) diff --git a/app/src/main/java/net/frju/flym/ui/entries/EntriesFragment.kt b/app/src/main/java/net/frju/flym/ui/entries/EntriesFragment.kt index 2bb1bf2f2..9318da76c 100644 --- a/app/src/main/java/net/frju/flym/ui/entries/EntriesFragment.kt +++ b/app/src/main/java/net/frju/flym/ui/entries/EntriesFragment.kt @@ -85,6 +85,7 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { } } + @ExperimentalStdlibApi var feed: Feed? = null set(value) { field = value @@ -95,6 +96,7 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { private val navigator: MainNavigator by lazy { activity as MainNavigator } + @ExperimentalStdlibApi private val adapter = EntryAdapter( displayThumbnails = context?.getPrefBoolean(PrefConstants.DISPLAY_THUMBNAILS, true) == true, globalClickListener = { entryWithFeed -> @@ -144,6 +146,7 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { setHasOptionsMenu(true) } + @ExperimentalStdlibApi override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) @@ -225,6 +228,7 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { } } + @ExperimentalStdlibApi private fun initDataObservers() { isDesc = context?.getPrefBoolean(PrefConstants.SORT_ORDER, true)!! entryIdsLiveData?.removeObservers(viewLifecycleOwner) @@ -289,6 +293,7 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { }) } + @ExperimentalStdlibApi override fun onStart() { super.onStart() context?.registerOnPrefChangeListener(prefListener) @@ -401,6 +406,7 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { context?.unregisterOnPrefChangeListener(prefListener) } + @ExperimentalStdlibApi override fun onSaveInstanceState(outState: Bundle) { outState.putParcelable(STATE_FEED, feed) outState.putString(STATE_SELECTED_ENTRY_ID, adapter.selectedEntryId) @@ -410,6 +416,7 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { super.onSaveInstanceState(outState) } + @ExperimentalStdlibApi private fun setupRecyclerView() { recycler_view.setHasFixedSize(true) @@ -502,6 +509,7 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { }) } + @ExperimentalStdlibApi private fun startRefresh() { if (context?.getPrefBoolean(PrefConstants.IS_REFRESHING, false) == false) { if (feed?.id != Feed.ALL_ENTRIES_ID) { @@ -516,6 +524,7 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { refresh_layout.postDelayed({ refreshSwipeProgress() }, 500) } + @ExperimentalStdlibApi private fun setupTitle() { activity?.toolbar?.apply { if (feed == null || feed?.id == Feed.ALL_ENTRIES_ID) { @@ -549,6 +558,7 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { return location[1] < resources.displayMetrics.heightPixels } + @ExperimentalStdlibApi override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) @@ -601,6 +611,7 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { }) } + @ExperimentalStdlibApi override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_entries__share -> { @@ -621,6 +632,7 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { return true } + @ExperimentalStdlibApi fun setSelectedEntryId(selectedEntryId: String) { adapter.selectedEntryId = selectedEntryId } diff --git a/app/src/main/java/net/frju/flym/ui/entrydetails/EntryDetailsFragment.kt b/app/src/main/java/net/frju/flym/ui/entrydetails/EntryDetailsFragment.kt index b8eeb3c70..0b1d808c8 100644 --- a/app/src/main/java/net/frju/flym/ui/entrydetails/EntryDetailsFragment.kt +++ b/app/src/main/java/net/frju/flym/ui/entrydetails/EntryDetailsFragment.kt @@ -111,6 +111,7 @@ class EntryDetailsFragment : Fragment() { entry_view.destroy() } + @ExperimentalStdlibApi override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) @@ -189,6 +190,7 @@ class EntryDetailsFragment : Fragment() { setEntry(arguments?.getString(ARG_ENTRY_ID)!!, arguments?.getStringArrayList(ARG_ALL_ENTRIES_IDS)!!) } + @ExperimentalStdlibApi private fun initDataObservers() { isMobilizingLiveData?.removeObservers(viewLifecycleOwner) refresh_layout.isRefreshing = false @@ -223,6 +225,7 @@ class EntryDetailsFragment : Fragment() { }) } + @ExperimentalStdlibApi private fun setupToolbar() { toolbar.apply { entryWithFeed?.let { entryWithFeed -> @@ -297,6 +300,7 @@ class EntryDetailsFragment : Fragment() { } } + @ExperimentalStdlibApi private fun switchFullTextMode() { // Enable this to test new manual mobilization // doAsync { @@ -337,6 +341,7 @@ class EntryDetailsFragment : Fragment() { } } + @ExperimentalStdlibApi fun setEntry(entryId: String, allEntryIds: List) { this.entryId = entryId this.allEntryIds = allEntryIds diff --git a/app/src/main/java/net/frju/flym/ui/feeds/FeedListEditFragment.kt b/app/src/main/java/net/frju/flym/ui/feeds/FeedListEditFragment.kt index e1f9b36f5..b62953994 100644 --- a/app/src/main/java/net/frju/flym/ui/feeds/FeedListEditFragment.kt +++ b/app/src/main/java/net/frju/flym/ui/feeds/FeedListEditFragment.kt @@ -42,6 +42,7 @@ class FeedListEditFragment : Fragment() { private val feedGroups = mutableListOf() private val feedAdapter = EditFeedAdapter(feedGroups) + @ExperimentalStdlibApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val view = inflater.inflate(R.layout.fragment_feed_list_edit, container, false) @@ -115,6 +116,7 @@ class FeedListEditFragment : Fragment() { inflater.inflate(R.menu.menu_fragment_feed_list_edit, menu) } + @ExperimentalStdlibApi override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.add_group -> { @@ -146,6 +148,7 @@ class FeedListEditFragment : Fragment() { return false } + @ExperimentalStdlibApi private fun changeItemPriority(fromFeed: Feed, newDisplayPriority: Int) { fromFeed.displayPriority = newDisplayPriority diff --git a/app/src/main/java/net/frju/flym/ui/main/MainActivity.kt b/app/src/main/java/net/frju/flym/ui/main/MainActivity.kt index e0070facd..726f8574b 100644 --- a/app/src/main/java/net/frju/flym/ui/main/MainActivity.kt +++ b/app/src/main/java/net/frju/flym/ui/main/MainActivity.kt @@ -25,6 +25,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.graphics.Color import android.net.Uri +import android.os.Build import android.os.Bundle import android.os.Environment import android.provider.OpenableColumns @@ -62,28 +63,15 @@ import net.frju.flym.ui.feeds.FeedAdapter import net.frju.flym.ui.feeds.FeedGroup import net.frju.flym.ui.feeds.FeedListEditActivity import net.frju.flym.ui.settings.SettingsActivity +import net.frju.flym.ui.settings.SettingsFragment import net.frju.flym.utils.* -import org.jetbrains.anko.AnkoLogger -import org.jetbrains.anko.browse -import org.jetbrains.anko.doAsync -import org.jetbrains.anko.notificationManager +import org.jetbrains.anko.* import org.jetbrains.anko.sdk21.listeners.onClick -import org.jetbrains.anko.startActivity -import org.jetbrains.anko.textColor -import org.jetbrains.anko.textResource -import org.jetbrains.anko.toast -import org.jetbrains.anko.uiThread import pub.devrel.easypermissions.AfterPermissionGranted import pub.devrel.easypermissions.EasyPermissions -import java.io.BufferedInputStream -import java.io.File -import java.io.InputStreamReader -import java.io.OutputStreamWriter -import java.io.Reader -import java.io.StringReader -import java.io.Writer +import java.io.* import java.net.URL -import java.util.Date +import java.util.* class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { @@ -113,6 +101,7 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { private val feedGroups = mutableListOf() private val feedAdapter = FeedAdapter(feedGroups) + @ExperimentalStdlibApi override fun onCreate(savedInstanceState: Bundle?) { setupNoActionBarTheme() @@ -285,10 +274,34 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { goToEntriesList(null) } + if (Build.VERSION.SDK_INT >= 29 && !Environment.isExternalStorageLegacy()) { + if (getPrefBoolean(PrefConstants.DECSYNC_ENABLED, false) && + !getPrefBoolean(PrefConstants.DECSYNC_USE_SAF, false)) { + putPrefBoolean(PrefConstants.DECSYNC_ENABLED, false) + putPrefBoolean(PrefConstants.UPDATE_FORCES_SAF, true) + } + putPrefBoolean(PrefConstants.DECSYNC_USE_SAF, true) + } + if (getPrefBoolean(PrefConstants.UPDATE_FORCES_SAF, false)) { + AlertDialog.Builder(this) + .setTitle(R.string.saf_update) + .setPositiveButton(android.R.string.ok) { _, _ -> + putPrefBoolean(PrefConstants.UPDATE_FORCES_SAF, false) + startActivity(SettingsFragment.EXTRA_SELECT_SAF_DIR to true) + } + .setNegativeButton(R.string.disable_decsync) { _, _ -> + putPrefBoolean(PrefConstants.UPDATE_FORCES_SAF, false) + } + .show() + } + if (getPrefBoolean(PrefConstants.REFRESH_ON_STARTUP, defValue = true)) { startService(Intent(this, FetcherService::class.java) .setAction(FetcherService.ACTION_REFRESH_FEEDS) .putExtra(FetcherService.FROM_AUTO_REFRESH, true)) + } else if (getPrefBoolean(PrefConstants.DECSYNC_ENABLED, false)) { + val extra = Extra() + DecsyncUtils.getDecsync(this)?.executeAllNewEntries(extra, true) } AutoRefreshJobService.initAutoRefresh(this) @@ -327,6 +340,7 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { } } + @ExperimentalStdlibApi override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) @@ -335,6 +349,7 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { handleImplicitIntent(intent) } + @ExperimentalStdlibApi private fun handleImplicitIntent(intent: Intent?) { // Has to be called on onStart (when the app is closed) and on onNewIntent (when the app is in the background) @@ -423,6 +438,7 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { } } + @ExperimentalStdlibApi override fun goToEntriesList(feed: Feed?) { clearDetails() containers_layout.state = MainNavigator.State.TWO_COLUMNS_EMPTY @@ -443,6 +459,7 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { override fun goToFeedSearch() = DiscoverActivity.newInstance(this) + @ExperimentalStdlibApi override fun goToEntryDetails(entryId: String, allEntryIds: List) { closeKeyboard() @@ -466,6 +483,7 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { } } + @ExperimentalStdlibApi override fun setSelectedEntryId(selectedEntryId: String) { val listFragment = supportFragmentManager.findFragmentById(R.id.frame_master) as EntriesFragment listFragment.setSelectedEntryId(selectedEntryId) @@ -479,6 +497,7 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { startActivity() } + @ExperimentalStdlibApi private fun openInBrowser(entryId: String) { doAsync { App.db.entryDao().findByIdWithFeed(entryId)?.entry?.link?.let { url -> @@ -534,6 +553,7 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { startActivityForResult(intent, WRITE_OPML_REQUEST_CODE) } + @ExperimentalStdlibApi override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) { super.onActivityResult(requestCode, resultCode, resultData) @@ -544,6 +564,7 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { } } + @ExperimentalStdlibApi @AfterPermissionGranted(AUTO_IMPORT_OPML_REQUEST_CODE) private fun autoImportOpml() { if (!EasyPermissions.hasPermissions(this, *NEEDED_PERMS)) { @@ -557,6 +578,7 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { } } + @ExperimentalStdlibApi private fun importOpml(uri: Uri) { doAsync { try { @@ -590,6 +612,7 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { } } + @ExperimentalStdlibApi private fun parseOpml(opmlReader: Reader) { var genId = 1L val feedList = mutableListOf() @@ -627,7 +650,7 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { } if (feedList.isNotEmpty()) { - App.db.feedDao().insert(*feedList.toTypedArray()) + App.db.feedDao().insert(feedList) } } diff --git a/app/src/main/java/net/frju/flym/ui/settings/SettingsFragment.kt b/app/src/main/java/net/frju/flym/ui/settings/SettingsFragment.kt index cbe212277..941d6ae13 100644 --- a/app/src/main/java/net/frju/flym/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/net/frju/flym/ui/settings/SettingsFragment.kt @@ -17,27 +17,45 @@ package net.frju.flym.ui.settings +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager import android.os.Bundle +import androidx.core.content.ContextCompat import androidx.preference.CheckBoxPreference import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat +import com.nononsenseapps.filepicker.FilePickerActivity +import com.nononsenseapps.filepicker.Utils import net.fred.feedex.R +import net.frju.flym.data.utils.PrefConstants.DECSYNC_ENABLED +import net.frju.flym.data.utils.PrefConstants.DECSYNC_FILE +import net.frju.flym.data.utils.PrefConstants.DECSYNC_USE_SAF import net.frju.flym.data.utils.PrefConstants.REFRESH_ENABLED import net.frju.flym.data.utils.PrefConstants.REFRESH_INTERVAL import net.frju.flym.data.utils.PrefConstants.THEME import net.frju.flym.service.AutoRefreshJobService import net.frju.flym.ui.main.MainActivity import net.frju.flym.ui.views.AutoSummaryListPreference +import net.frju.flym.utils.* +import org.decsync.library.DecsyncPrefUtils import org.jetbrains.anko.support.v4.startActivity - class SettingsFragment : PreferenceFragmentCompat() { + companion object { + private const val CHOOSE_DECSYNC_FILE = 0 + private const val PERMISSIONS_REQUEST_DECSYNC = 2 + const val EXTRA_SELECT_SAF_DIR = "select_saf_dir" + } + private val onRefreshChangeListener = Preference.OnPreferenceChangeListener { _, _ -> AutoRefreshJobService.initAutoRefresh(requireContext()) true } + @ExperimentalStdlibApi override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.settings, rootKey) @@ -50,5 +68,85 @@ class SettingsFragment : PreferenceFragmentCompat() { startActivity() true } + + if (requireContext().getPrefBoolean(DECSYNC_USE_SAF, false)) { + findPreference(DECSYNC_ENABLED)?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + if (newValue == true) { + DecsyncPrefUtils.chooseDecsyncDir(this) + return@OnPreferenceChangeListener false + } + true + } + } else { + findPreference(DECSYNC_ENABLED)?.summary = + if (requireContext().getPrefBoolean(DECSYNC_ENABLED, false)) + requireContext().getPrefString(DECSYNC_FILE, defaultDecsyncDir) + else + getString(R.string.settings_decsync_enabled_description) + findPreference(DECSYNC_ENABLED)?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { preference, newValue -> + if (newValue == true) { + if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) { + chooseDecsyncFile() + } else { + requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), PERMISSIONS_REQUEST_DECSYNC) + } + false + } else { + preference.summary = getString(R.string.settings_decsync_enabled_description) + true + } + } + } + } + + override fun onBindPreferences() { + if (requireActivity().intent.getBooleanExtra(EXTRA_SELECT_SAF_DIR, false)) { + scrollToPreference(DECSYNC_ENABLED) + DecsyncPrefUtils.chooseDecsyncDir(this) + } + } + + private fun chooseDecsyncFile() { + val intent = Intent(requireContext(), FilePickerActivity::class.java) + intent.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true) + intent.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR) + // Always start on the default DecSync dir, as the previously selected one may be inaccessible + intent.putExtra(FilePickerActivity.EXTRA_START_PATH, defaultDecsyncDir) + startActivityForResult(intent, CHOOSE_DECSYNC_FILE) + } + + @ExperimentalStdlibApi + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requireContext().getPrefBoolean(DECSYNC_USE_SAF, false)) { + DecsyncPrefUtils.chooseDecsyncDirResult(requireContext(), requestCode, resultCode, data) { + requireContext().putPrefBoolean(DECSYNC_ENABLED, true) + findPreference(DECSYNC_ENABLED)?.isChecked = true + if (!requireActivity().intent.getBooleanExtra(EXTRA_SELECT_SAF_DIR, false)) { + DecsyncUtils.initSync(requireContext()) + } + } + } else { + if (requestCode == CHOOSE_DECSYNC_FILE) { + val uri = data?.data + if (resultCode == Activity.RESULT_OK && uri != null) { + requireContext().putPrefBoolean(DECSYNC_ENABLED, true) + val dir = Utils.getFileForUri(uri).path + requireContext().putPrefString(DECSYNC_FILE, dir) + findPreference(DECSYNC_ENABLED)?.isChecked = true + findPreference(DECSYNC_ENABLED)?.summary = dir + DecsyncUtils.initSync(requireContext()) + } + } + } + } + + @ExperimentalStdlibApi + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + when (requestCode) { + PERMISSIONS_REQUEST_DECSYNC -> if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + chooseDecsyncFile() + } + } } } \ No newline at end of file diff --git a/app/src/main/java/net/frju/flym/utils/DecsyncUtils.kt b/app/src/main/java/net/frju/flym/utils/DecsyncUtils.kt new file mode 100644 index 000000000..9de3c97df --- /dev/null +++ b/app/src/main/java/net/frju/flym/utils/DecsyncUtils.kt @@ -0,0 +1,210 @@ +package net.frju.flym.utils + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.graphics.BitmapFactory +import android.os.Build +import android.os.Environment +import android.util.Log +import androidx.core.app.NotificationCompat +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import net.fred.feedex.R +import net.frju.flym.App +import net.frju.flym.data.entities.Feed +import net.frju.flym.data.utils.PrefConstants.DECSYNC_ENABLED +import net.frju.flym.data.utils.PrefConstants.DECSYNC_FILE +import net.frju.flym.data.utils.PrefConstants.DECSYNC_USE_SAF +import net.frju.flym.data.utils.PrefConstants.UPDATE_FORCES_SAF +import net.frju.flym.service.FetcherService +import org.decsync.library.Decsync +import org.decsync.library.DecsyncPrefUtils +import org.decsync.library.getAppId +import org.jetbrains.anko.notificationManager +import java.io.File + +val ownAppId = getAppId("Flym") +val defaultDecsyncDir = "${Environment.getExternalStorageDirectory()}/DecSync" +private const val TAG = "DecsyncUtils" +private const val ERROR_NOTIFICATION_ID = 1 + +class Extra + +@ExperimentalStdlibApi +object DecsyncUtils { + private var mDecsync: Decsync? = null + + private fun getNewDecsync(context: Context): Decsync { + val decsync = if (context.getPrefBoolean(DECSYNC_USE_SAF, false)) { + val decsyncDir = DecsyncPrefUtils.getDecsyncDir(context) ?: throw Exception(context.getString(R.string.settings_decsync_dir_not_configured)) + Decsync(context, decsyncDir, "rss", null, ownAppId) + } else { + val decsyncDir = File(context.getPrefString(DECSYNC_FILE, defaultDecsyncDir)) + Decsync(decsyncDir, "rss", null, ownAppId) + } + decsync.addListener(listOf("articles", "read"), ::readListener) + decsync.addListener(listOf("articles", "marked"), ::markedListener) + decsync.addListener(listOf("feeds", "subscriptions"), ::subscriptionsListener) + decsync.addListener(listOf("feeds", "names"), ::feedNamesListener) + decsync.addListener(listOf("feeds", "categories"), ::categoriesListener) + decsync.addListener(listOf("categories", "names"), ::categoryNamesListener) + decsync.addListener(listOf("categories", "parents"), ::categoryParentsListener) + return decsync + } + + fun getDecsync(context: Context): Decsync? { + if (mDecsync == null && context.getPrefBoolean(DECSYNC_ENABLED, false)) { + if (Build.VERSION.SDK_INT >= 29 && + !Environment.isExternalStorageLegacy() && + !context.getPrefBoolean(DECSYNC_USE_SAF, false)) { + context.putPrefBoolean(DECSYNC_ENABLED, false) + context.putPrefBoolean(DECSYNC_USE_SAF, true) + context.putPrefBoolean(UPDATE_FORCES_SAF, true) + return null + } + try { + mDecsync = getNewDecsync(context) + } catch (e: Exception) { + Log.e(TAG, "", e) + context.putPrefBoolean(DECSYNC_ENABLED, false) + + val channelId = "channel_error" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + channelId, + context.getString(R.string.channel_error_name), + NotificationManager.IMPORTANCE_DEFAULT + ) + context.notificationManager.createNotificationChannel(channel) + } + val notification = NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_statusbar_rss) + .setLargeIcon( + BitmapFactory.decodeResource( + context.resources, + R.mipmap.ic_launcher + ) + ) + .setContentTitle(context.getString(R.string.decsync_disabled)) + .setContentText(e.localizedMessage) + .build() + context.notificationManager.notify(ERROR_NOTIFICATION_ID, notification) + } + } + return mDecsync + } + + fun initSync(context: Context) { + mDecsync = null + context.startService(Intent(context, FetcherService::class.java) + .setAction(FetcherService.ACTION_REFRESH_FEEDS) + .putExtra(FetcherService.FROM_INIT_SYNC, true)) + } + + private fun readListener(path: List, entry: Decsync.Entry, extra: Extra) { + Log.d(TAG, "Execute read entry $entry") + val uri = entry.key.jsonPrimitive.content + val value = entry.value.jsonPrimitive.boolean + val id = App.db.entryDao().idForUri(uri) ?: run { + Log.i(TAG, "Unknown article $uri") + return + } + if (value) { + App.db.entryDao().markAsRead(listOf(id), false) + } else { + App.db.entryDao().markAsUnread(listOf(id), false) + } + } + + private fun markedListener(path: List, entry: Decsync.Entry, extra: Extra) { + Log.d(TAG, "Execute mark entry $entry") + val uri = entry.key.jsonPrimitive.content + val value = entry.value.jsonPrimitive.boolean + val id = App.db.entryDao().idForUri(uri) ?: run { + Log.i(TAG, "Unknown article $uri") + return + } + if (value) { + App.db.entryDao().markAsFavorite(id, false) + } else { + App.db.entryDao().markAsNotFavorite(id, false) + } + } + + private fun subscriptionsListener(path: List, entry: Decsync.Entry, extra: Extra) { + Log.d(TAG, "Execute subscribe entry $entry") + val link = entry.key.jsonPrimitive.content + val subscribed = entry.value.jsonPrimitive.boolean + if (subscribed) { + if (App.db.feedDao().findByLink(link) == null) { + App.db.feedDao().insert(Feed(link = link), false) + } + } else { + val feed = App.db.feedDao().findByLink(link) ?: run { + Log.i(TAG, "Unknown feed $link") + return + } + val groupId = feed.groupId + App.db.feedDao().delete(feed, false) + removeGroupIfEmpty(groupId) + } + } + + private fun feedNamesListener(path: List, entry: Decsync.Entry, extra: Extra) { + Log.d(TAG, "Execute rename entry $entry") + val link = entry.key.jsonPrimitive.content + val name = entry.value.jsonPrimitive.content + val feed = App.db.feedDao().findByLink(link) ?: run { + Log.i(TAG, "Unknown feed $link") + return + } + feed.title = name + App.db.feedDao().update(feed) + } + + private fun categoriesListener(path: List, entry: Decsync.Entry, extra: Extra) { + Log.d(TAG, "Execute move entry $entry") + val link = entry.key.jsonPrimitive.content + val catId = entry.value.jsonPrimitive.contentOrNull + val feed = App.db.feedDao().findByLink(link) ?: run { + Log.i(TAG, "Unknown feed $link") + return + } + val oldGroupId = feed.groupId + val groupId = catId?.let { + App.db.feedDao().findByLink(catId)?.id ?: run { + val group = Feed(link = catId, title = catId, isGroup = true) + App.db.feedDao().insert(group, false) + } + } + feed.groupId = groupId + App.db.feedDao().update(feed, false) + removeGroupIfEmpty(oldGroupId) + } + + private fun categoryNamesListener(path: List, entry: Decsync.Entry, extra: Extra) { + Log.d(TAG, "Execute category rename entry $entry") + val catId = entry.key.jsonPrimitive.content + val name = entry.value.jsonPrimitive.content + val group = App.db.feedDao().findByLink(catId) ?: run { + Log.i(TAG, "Unknown category $catId") + return + } + group.title = name + App.db.feedDao().update(group, false) + } + + private fun categoryParentsListener(path: List, entry: Decsync.Entry, extra: Extra) { + Log.i(TAG, "Nested categories are not supported") + } + + private fun removeGroupIfEmpty(groupId: Long?) { + if (groupId == null) return + if (App.db.feedDao().allFeedsInGroup(groupId).isEmpty()) { + App.db.feedDao().deleteById(groupId, false) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c146bf9c..8f11c5cdc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -139,6 +139,18 @@ Article appearance If enabled, allows swiping between entries while viewing an entry + DecSync + About DecSync + Enable DecSync + Sync with other RSS readers + No DecSync directory configured + + + Due to an Android update you need to reselect the DecSync directory + Disable DecSync + DecSync support disabled + Errors + 5 minutes 15 minutes diff --git a/app/src/main/res/xml/settings.xml b/app/src/main/res/xml/settings.xml index f56946869..85bb3bb3d 100644 --- a/app/src/main/res/xml/settings.xml +++ b/app/src/main/res/xml/settings.xml @@ -199,4 +199,27 @@ + + + + + + + + + + \ No newline at end of file From f8294e9e302cea5ad1f93040002954cd24add1e5 Mon Sep 17 00:00:00 2001 From: Aldo Gunsing Date: Sat, 24 Oct 2020 22:26:09 +0200 Subject: [PATCH 2/6] Improve background updates to feeds/entries; don't overwrite concurrent modifications --- .../java/net/frju/flym/data/dao/FeedDao.kt | 3 ++ .../java/net/frju/flym/data/entities/Entry.kt | 6 +-- .../net/frju/flym/service/FetcherService.kt | 52 ++++++++++--------- 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/net/frju/flym/data/dao/FeedDao.kt b/app/src/main/java/net/frju/flym/data/dao/FeedDao.kt index 042a7a493..73bb2e499 100644 --- a/app/src/main/java/net/frju/flym/data/dao/FeedDao.kt +++ b/app/src/main/java/net/frju/flym/data/dao/FeedDao.kt @@ -68,6 +68,9 @@ abstract class FeedDao { @Query("UPDATE feeds SET retrieveFullText = 0 WHERE feedId = :feedId") abstract fun disableFullTextRetrieval(feedId: Long) + @Query("UPDATE feeds SET fetchError = 1 WHERE feedId = :feedId") + abstract fun setFetchError(feedId: Long) + @Insert(onConflict = OnConflictStrategy.REPLACE) protected abstract fun insertDao(vararg feeds: Feed): List diff --git a/app/src/main/java/net/frju/flym/data/entities/Entry.kt b/app/src/main/java/net/frju/flym/data/entities/Entry.kt index 96b88bade..f9fceac0d 100644 --- a/app/src/main/java/net/frju/flym/data/entities/Entry.kt +++ b/app/src/main/java/net/frju/flym/data/entities/Entry.kt @@ -102,11 +102,11 @@ data class Entry(@PrimaryKey } } -fun SyndEntry.toDbFormat(context: Context, feed: Feed): Entry { +fun SyndEntry.toDbFormat(context: Context, feedId: Long): Entry { val item = Entry() - item.id = (feed.id.toString() + "_" + (link ?: uri ?: title + item.id = (feedId.toString() + "_" + (link ?: uri ?: title ?: UUID.randomUUID().toString())).sha1() - item.feedId = feed.id + item.feedId = feedId if (title != null) { item.title = HtmlCompat.fromHtml(title, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() } else { diff --git a/app/src/main/java/net/frju/flym/service/FetcherService.kt b/app/src/main/java/net/frju/flym/service/FetcherService.kt index c83567231..a26b6bc8e 100644 --- a/app/src/main/java/net/frju/flym/service/FetcherService.kt +++ b/app/src/main/java/net/frju/flym/service/FetcherService.kt @@ -192,11 +192,11 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { if (feedId == 0L || App.db.feedDao().findById(feedId)!!.isGroup) { newCount = refreshFeeds(feedId, acceptMinDate) } else { - App.db.feedDao().findById(feedId)?.let { + App.db.feedDao().findById(feedId)?.link?.let { link -> try { - newCount = refreshFeed(it, acceptMinDate) + newCount = refreshFeed(feedId, link, acceptMinDate) } catch (e: Exception) { - error("Can't fetch feed ${it.link}", e) + error("Can't fetch feed $link", e) } } } @@ -334,14 +334,14 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { for (task in tasks) { var success = false - App.db.entryDao().findById(task.entryId)?.let { entry -> - entry.link?.let { link -> - try { - createCall(link).execute().use { response -> - response.body?.byteStream()?.let { input -> - Readability4JExtended(link, Jsoup.parse(input, null, link)).parse().articleContent?.html()?.let { - val mobilizedHtml = HtmlUtils.improveHtmlContent(it, getBaseUrl(link)) + App.db.entryDao().findById(task.entryId)?.link?.let { link -> + try { + createCall(link).execute().use { response -> + response.body?.byteStream()?.let { input -> + Readability4JExtended(link, Jsoup.parse(input, null, link)).parse().articleContent?.html()?.let { + val mobilizedHtml = HtmlUtils.improveHtmlContent(it, getBaseUrl(link)) + App.db.entryDao().findById(task.entryId)?.let { entry -> val entryDescription = entry.description if (entryDescription == null || HtmlCompat.fromHtml(mobilizedHtml, HtmlCompat.FROM_HTML_MODE_LEGACY).length > HtmlCompat.fromHtml(entryDescription, HtmlCompat.FROM_HTML_MODE_LEGACY).length) { // If the retrieved text is smaller than the original one, then we certainly failed... if (downloadPictures) { @@ -366,9 +366,9 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { } } } - } catch (t: Throwable) { - error("Can't mobilize feedWithCount ${entry.link}", t) } + } catch (t: Throwable) { + error("Can't mobilize feedWithCount $link", t) } } @@ -428,7 +428,7 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { completionService.submit { var result = 0 try { - result = refreshFeed(feed, acceptMinDate) + result = refreshFeed(feed.id, feed.link, acceptMinDate) } catch (e: Exception) { error("Can't fetch feedWithCount ${feed.link}", e) } @@ -451,30 +451,32 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { } @ExperimentalStdlibApi - private fun refreshFeed(feed: Feed, acceptMinDate: Long): Int { + private fun refreshFeed(feedId: Long, feedLink: String, acceptMinDate: Long): Int { val entries = mutableListOf() val entriesToInsert = mutableListOf() val imgUrlsToDownload = mutableMapOf>() val downloadPictures = shouldDownloadPictures() - val previousFeedState = feed.copy() try { - createCall(feed.link).execute().use { response -> + createCall(feedLink).execute().use { response -> val input = SyndFeedInput() val romeFeed = input.build(XmlReader(response.body!!.byteStream())) - entries.addAll(romeFeed.entries.asSequence().filter { it.publishedDate?.time ?: Long.MAX_VALUE > acceptMinDate }.map { it.toDbFormat(context, feed) }) - feed.update(romeFeed) + entries.addAll(romeFeed.entries.asSequence().filter { it.publishedDate?.time ?: Long.MAX_VALUE > acceptMinDate }.map { it.toDbFormat(context, feedId) }) + App.db.feedDao().findById(feedId)?.let { feed -> + val previousFeedState = feed.copy() + feed.update(romeFeed) + if (feed != previousFeedState) { + App.db.feedDao().update(feed) + } + } } } catch (t: Throwable) { - feed.fetchError = true + App.db.feedDao().setFetchError(feedId) } - if (feed != previousFeedState) { - App.db.feedDao().update(feed) - } // First we remove the entries that we already have in db (no update to save data) - val existingIds = App.db.entryDao().idsForFeed(feed.id) + val existingIds = App.db.entryDao().idsForFeed(feedId) entries.removeAll { it.id in existingIds } // Second, we filter items with same title than one we already have @@ -499,7 +501,7 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { } } - val feedBaseUrl = getBaseUrl(feed.link) + val feedBaseUrl = getBaseUrl(feedLink) var foundExisting = false // Now we improve the html and find images @@ -540,7 +542,7 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { // Insert everything App.db.entryDao().insert(entriesToInsert) - if (feed.retrieveFullText) { + if (App.db.feedDao().findById(feedId)?.retrieveFullText == true) { addEntriesToMobilize(entries.map { it.id }) } From 68f21eecd3ff1f8e9c5b428bb82eb7bcd4056268 Mon Sep 17 00:00:00 2001 From: Aldo Gunsing Date: Sat, 24 Oct 2020 22:43:13 +0200 Subject: [PATCH 3/6] Simplify the DecSync implementation Use the DecsyncObserver introduced in libdecsync v1.8.0 --- app/build.gradle | 5 +- app/src/main/java/net/frju/flym/App.kt | 66 +++++- .../java/net/frju/flym/data/AppDatabase.kt | 11 + .../java/net/frju/flym/data/dao/EntryDao.kt | 124 ++-------- .../java/net/frju/flym/data/dao/FeedDao.kt | 142 ++---------- .../frju/flym/data/entities/DecsyncArticle.kt | 22 ++ .../frju/flym/data/entities/DecsyncFeed.kt | 31 +++ .../java/net/frju/flym/data/entities/Entry.kt | 36 --- .../flym/service/AutoRefreshJobService.kt | 2 + .../net/frju/flym/service/FetcherService.kt | 66 +----- .../frju/flym/ui/discover/DiscoverActivity.kt | 4 - .../frju/flym/ui/entries/EntriesFragment.kt | 12 - .../ui/entrydetails/EntryDetailsFragment.kt | 5 - .../flym/ui/feeds/FeedListEditFragment.kt | 3 - .../net/frju/flym/ui/main/MainActivity.kt | 17 +- .../frju/flym/ui/settings/SettingsFragment.kt | 4 +- .../net/frju/flym/utils/DecsyncListeners.kt | 125 ++++++++++ .../java/net/frju/flym/utils/DecsyncUtils.kt | 214 +++++------------- 18 files changed, 369 insertions(+), 520 deletions(-) create mode 100644 app/src/main/java/net/frju/flym/data/entities/DecsyncArticle.kt create mode 100644 app/src/main/java/net/frju/flym/data/entities/DecsyncFeed.kt create mode 100644 app/src/main/java/net/frju/flym/utils/DecsyncListeners.kt diff --git a/app/build.gradle b/app/build.gradle index ba074452c..04b864922 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -122,7 +122,8 @@ dependencies { implementation 'net.dankito.readability4j:readability4j:1.0.5' implementation 'pub.devrel:easypermissions:3.0.0' implementation 'com.rometools:rome-opml:1.15.0' - implementation 'org.jetbrains.kotlinx:kotlinx-serialization-core:1.0.0-RC' - implementation 'org.decsync:libdecsync:1.7.1' + implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.0' + implementation 'org.decsync:libdecsync:1.8.0' implementation 'com.nononsenseapps:filepicker:4.1.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9' } diff --git a/app/src/main/java/net/frju/flym/App.kt b/app/src/main/java/net/frju/flym/App.kt index 681697454..ee11b7b1a 100644 --- a/app/src/main/java/net/frju/flym/App.kt +++ b/app/src/main/java/net/frju/flym/App.kt @@ -25,10 +25,19 @@ import android.os.StrictMode import android.os.StrictMode.VmPolicy import android.os.strictmode.UntaggedSocketViolation import android.util.Log +import androidx.lifecycle.Observer +import kotlinx.coroutines.ObsoleteCoroutinesApi import net.fred.feedex.BuildConfig import net.frju.flym.data.AppDatabase +import net.frju.flym.data.entities.DecsyncArticle +import net.frju.flym.data.entities.DecsyncCategory +import net.frju.flym.data.entities.DecsyncFeed import net.frju.flym.data.utils.PrefConstants -import net.frju.flym.utils.putPrefBoolean +import net.frju.flym.utils.* +import org.decsync.library.Decsync +import org.decsync.library.DecsyncItem +import org.decsync.library.DecsyncObserver +import org.decsync.library.items.Rss import java.util.concurrent.Executors @@ -43,8 +52,56 @@ class App : Application() { @JvmStatic lateinit var db: AppDatabase private set + + @ExperimentalStdlibApi + @ObsoleteCoroutinesApi + private abstract class MyDecsyncObserver : DecsyncObserver(), Observer> { + abstract fun toDecsyncItem(item: T): DecsyncItem + + override fun isDecsyncEnabled(): Boolean { + return context.getPrefBoolean(PrefConstants.DECSYNC_ENABLED, false) + } + + override fun setEntries(entries: List) { + DecsyncUtils.withDecsync(context) { setEntries(entries) } + } + + override fun executeStoredEntries(storedEntries: List) { + DecsyncUtils.withDecsync(context) { executeStoredEntries(storedEntries, Extra()) } + } + + override fun onChanged(newList: List) { + updateList(newList.map { toDecsyncItem(it) }) + } + } + + @ExperimentalStdlibApi + @ObsoleteCoroutinesApi + private val articleObserver = object: MyDecsyncObserver() { + override fun toDecsyncItem(item: DecsyncArticle): Rss.Article = item.getRssArticle() + } + @ExperimentalStdlibApi + @ObsoleteCoroutinesApi + private val feedObserver = object: MyDecsyncObserver() { + override fun toDecsyncItem(item: DecsyncFeed): Rss.Feed = item.getRssFeed() + } + @ExperimentalStdlibApi + @ObsoleteCoroutinesApi + private val categoryObserver = object: MyDecsyncObserver() { + override fun toDecsyncItem(item: DecsyncCategory): Rss.Category = item.getRssCategory() + } + + @ExperimentalStdlibApi + @ObsoleteCoroutinesApi + fun initSync() { + articleObserver.initSync() + feedObserver.initSync() + categoryObserver.initSync() + } } + @ExperimentalStdlibApi + @ObsoleteCoroutinesApi override fun onCreate() { super.onCreate() @@ -73,5 +130,10 @@ class App : Application() { } StrictMode.setVmPolicy(vmPolicy.build()) } + + // Add DecSync observers + db.entryDao().observeAllDecsyncArticles.observeForever(articleObserver) + db.feedDao().observeAllDecsyncFeeds.observeForever(feedObserver) + db.feedDao().observeAllDecsyncCategories.observeForever(categoryObserver) } -} +} \ No newline at end of file diff --git a/app/src/main/java/net/frju/flym/data/AppDatabase.kt b/app/src/main/java/net/frju/flym/data/AppDatabase.kt index a19f85b1d..d44e9df24 100644 --- a/app/src/main/java/net/frju/flym/data/AppDatabase.kt +++ b/app/src/main/java/net/frju/flym/data/AppDatabase.kt @@ -76,6 +76,7 @@ abstract class AppDatabase : RoomDatabase() { database.run { execSQL("ALTER TABLE entries ADD COLUMN uri TEXT") execSQL("CREATE UNIQUE INDEX index_entries_uri ON entries (uri)") + execSQL("UPDATE feeds SET feedLink = 'catID' || substr('00000' || abs(random() % 100000), -5) WHERE isGroup = 1 AND feedLink = ''") } } } @@ -111,6 +112,16 @@ abstract class AppDatabase : RoomDatabase() { UPDATE feeds SET displayPriority = (SELECT COUNT() + 1 FROM feeds f WHERE f.displayPriority < NEW.displayPriority AND f.groupId IS NEW.groupId ) WHERE feedId = NEW.feedId; END; """) + + // give new groups a random catID by default + db.execSQL(""" + CREATE TRIGGER group_insert_catid + AFTER INSERT + ON feeds + BEGIN + UPDATE feeds SET feedLink = 'catID' || substr('00000' || abs(random() % 100000), -5) WHERE feedId = NEW.feedId AND isGroup = 1 AND feedLink = ''; + END; + """) } } }) diff --git a/app/src/main/java/net/frju/flym/data/dao/EntryDao.kt b/app/src/main/java/net/frju/flym/data/dao/EntryDao.kt index 2d2a5f6df..111da2ad8 100644 --- a/app/src/main/java/net/frju/flym/data/dao/EntryDao.kt +++ b/app/src/main/java/net/frju/flym/data/dao/EntryDao.kt @@ -25,15 +25,12 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update -import net.frju.flym.App +import net.frju.flym.data.entities.DecsyncArticle import net.frju.flym.data.entities.Entry import net.frju.flym.data.entities.EntryWithFeed -import net.frju.flym.data.utils.PrefConstants.DECSYNC_ENABLED -import net.frju.flym.utils.DecsyncUtils -import net.frju.flym.utils.Extra -import net.frju.flym.utils.getPrefBoolean -import org.decsync.library.Decsync +private const val DECSYNC_ARTICLE_SELECT = "uri, read, favorite, publicationDate" +private const val DECSYNC_ARTICLE_WHERE = "uri NOT NULL AND publicationDate != fetchDate" private const val LIGHT_SELECT = "id, entries.feedId, feedLink, feedTitle, fetchDate, publicationDate, title, link, description, imageLink, read, favorite" private const val ORDER_BY = "ORDER BY CASE WHEN :isDesc = 1 THEN publicationDate END DESC, CASE WHEN :isDesc = 0 THEN publicationDate END ASC, id" private const val JOIN = "entries INNER JOIN feeds ON entries.feedId = feeds.feedId" @@ -44,6 +41,10 @@ private const val LIKE_SEARCH = "LIKE '%' || :searchText || '%'" @Dao abstract class EntryDao { + @ExperimentalStdlibApi + @get:Query("SELECT $DECSYNC_ARTICLE_SELECT FROM entries WHERE $DECSYNC_ARTICLE_WHERE") + abstract val observeAllDecsyncArticles: LiveData> + @Query("SELECT $LIGHT_SELECT FROM $JOIN WHERE title $LIKE_SEARCH OR description $LIKE_SEARCH OR mobilizedContent $LIKE_SEARCH $ORDER_BY") abstract fun observeSearch(searchText: String, isDesc: Boolean): DataSource.Factory @@ -150,126 +151,31 @@ abstract class EntryDao { abstract fun unreadIdsForGroup(groupId: Long): List @Query("UPDATE entries SET read = 1 WHERE id IN (:ids)") - protected abstract fun markAsReadDao(ids: List) - - @ExperimentalStdlibApi - fun markAsRead(ids: List, updateDecsync: Boolean = true) { - if (updateDecsync && App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { - val entries = ids.mapNotNull { id -> - getReadMarkEntry(id, "read", true) - } - DecsyncUtils.getDecsync(App.context)?.setEntries(entries) - } - markAsReadDao(ids) - } + abstract fun markAsRead(ids: List) @Query("UPDATE entries SET read = 0 WHERE id IN (:ids)") - protected abstract fun markAsUnreadDao(ids: List) - - @ExperimentalStdlibApi - fun markAsUnread(ids: List, updateDecsync: Boolean = true) { - if (updateDecsync && App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { - val entries = ids.mapNotNull { id -> - getReadMarkEntry(id, "read", false) - } - DecsyncUtils.getDecsync(App.context)?.setEntries(entries) - } - markAsUnreadDao(ids) - } + abstract fun markAsUnread(ids: List) @Query("UPDATE entries SET read = 1 WHERE feedId = :feedId") - protected abstract fun markAsReadDao(feedId: Long) - - @ExperimentalStdlibApi - fun markAsRead(feedId: Long, updateDecsync: Boolean = true) { - if (updateDecsync && App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { - val entries = unreadIdsForFeed(feedId).mapNotNull { id -> - getReadMarkEntry(id, "read", true) - } - DecsyncUtils.getDecsync(App.context)?.setEntries(entries) - } - markAsReadDao(feedId) - } + abstract fun markAsRead(feedId: Long) @Query("UPDATE entries SET read = 1 WHERE feedId IN (SELECT feedId FROM feeds WHERE groupId = :groupId)") - protected abstract fun markGroupAsReadDao(groupId: Long) - - @ExperimentalStdlibApi - fun markGroupAsRead(groupId: Long, updateDecsync: Boolean = true) { - if (updateDecsync && App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { - val entries = unreadIdsForGroup(groupId).mapNotNull { id -> - getReadMarkEntry(id, "read", true) - } - DecsyncUtils.getDecsync(App.context)?.setEntries(entries) - } - markGroupAsReadDao(groupId) - } + abstract fun markGroupAsRead(groupId: Long) @Query("UPDATE entries SET read = 1") - protected abstract fun markAllAsReadDao() - - @ExperimentalStdlibApi - fun markAllAsRead(updateDecsync: Boolean = true) { - if (updateDecsync && App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { - val entries = unreadIds.mapNotNull { id -> - getReadMarkEntry(id, "read", true) - } - DecsyncUtils.getDecsync(App.context)?.setEntries(entries) - } - markAllAsReadDao() - } + abstract fun markAllAsRead() @Query("UPDATE entries SET favorite = 1 WHERE id IS :id") - protected abstract fun markAsFavoriteDao(id: String) - - @ExperimentalStdlibApi - fun markAsFavorite(id: String, updateDecsync: Boolean = true) { - if (updateDecsync && App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { - writeReadMarkEntry(id, "marked", true) - } - markAsFavoriteDao(id) - } + abstract fun markAsFavorite(id: String) @Query("UPDATE entries SET favorite = 0 WHERE id IS :id") - protected abstract fun markAsNotFavoriteDao(id: String) - - @ExperimentalStdlibApi - fun markAsNotFavorite(id: String, updateDecsync: Boolean = true) { - if (updateDecsync && App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { - writeReadMarkEntry(id, "marked", false) - } - markAsNotFavoriteDao(id) - } - - @ExperimentalStdlibApi - fun getReadMarkEntry(id: String, type: String, value: Boolean): Decsync.EntryWithPath? { - val entry = findById(id) ?: return null - return entry.getDecsyncEntry(type, value) - } - - @ExperimentalStdlibApi - private fun writeReadMarkEntry(id: String, type: String, value: Boolean) { - val entry = getReadMarkEntry(id, type, value) ?: return - DecsyncUtils.getDecsync(App.context)?.setEntries(listOf(entry)) - } + abstract fun markAsNotFavorite(id: String) @Query("DELETE FROM entries WHERE fetchDate < :keepDateBorderTime AND favorite = 0 AND read = :read") abstract fun deleteOlderThan(keepDateBorderTime: Long, read: Long) @Insert(onConflict = OnConflictStrategy.IGNORE) // Ignore because we don't want to delete previously starred entries - protected abstract fun insertDao(vararg entries: Entry) - - @ExperimentalStdlibApi - fun insert(entries: List) { - insertDao(*entries.toTypedArray()) - val storedEntries = mutableListOf() - for (entry in entries) { - storedEntries.add(entry.getDecsyncStoredEntry("read") ?: continue) - storedEntries.add(entry.getDecsyncStoredEntry("marked") ?: continue) - } - val extra = Extra() - DecsyncUtils.getDecsync(App.context)?.executeStoredEntries(storedEntries, extra) - } + abstract fun insert(vararg entries: Entry) @Update abstract fun update(vararg entries: Entry) diff --git a/app/src/main/java/net/frju/flym/data/dao/FeedDao.kt b/app/src/main/java/net/frju/flym/data/dao/FeedDao.kt index 73bb2e499..d35b8fdb4 100644 --- a/app/src/main/java/net/frju/flym/data/dao/FeedDao.kt +++ b/app/src/main/java/net/frju/flym/data/dao/FeedDao.kt @@ -25,22 +25,29 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update -import kotlinx.serialization.json.JsonPrimitive -import net.frju.flym.App +import net.frju.flym.data.entities.DecsyncCategory +import net.frju.flym.data.entities.DecsyncFeed import net.frju.flym.data.entities.Feed import net.frju.flym.data.entities.FeedWithCount -import net.frju.flym.data.utils.PrefConstants.DECSYNC_ENABLED -import net.frju.flym.utils.DecsyncUtils -import net.frju.flym.utils.Extra -import net.frju.flym.utils.getPrefBoolean -import org.decsync.library.Decsync import java.util.* +private const val DECSYNC_FEED_SELECT = "feedLink, feedTitle, groupId" +private const val DECSYNC_FEED_WHERE = "isGroup = 0 AND feedLink != ''" +private const val DECSYNC_CATEGORY_SELECT = "feedLink, feedTitle" +private const val DECSYNC_CATEGORY_WHERE = "isGroup = 1 AND feedLink != '' AND feedTitle NOT NULL" private const val ENTRY_COUNT = "(SELECT COUNT(*) FROM entries WHERE feedId IS f.feedId AND read = 0)" -private const val TAG = "FeedDao" @Dao abstract class FeedDao { + + @ExperimentalStdlibApi + @get:Query("SELECT $DECSYNC_FEED_SELECT FROM feeds WHERE $DECSYNC_FEED_WHERE") + abstract val observeAllDecsyncFeeds: LiveData> + + @ExperimentalStdlibApi + @get:Query("SELECT $DECSYNC_CATEGORY_SELECT FROM feeds WHERE $DECSYNC_CATEGORY_WHERE") + abstract val observeAllDecsyncCategories: LiveData> + @get:Query("SELECT * FROM feeds WHERE isGroup = 0") abstract val allNonGroupFeeds: List @@ -62,6 +69,9 @@ abstract class FeedDao { @Query("SELECT * FROM feeds WHERE feedLink IS :link") abstract fun findByLink(link: String): Feed? + @Query("DELETE FROM feeds WHERE feedLink IS :link") + abstract fun deleteByLink(link: String) + @Query("UPDATE feeds SET retrieveFullText = 1 WHERE feedId = :feedId") abstract fun enableFullTextRetrieval(feedId: Long) @@ -72,121 +82,11 @@ abstract class FeedDao { abstract fun setFetchError(feedId: Long) @Insert(onConflict = OnConflictStrategy.REPLACE) - protected abstract fun insertDao(vararg feeds: Feed): List - - @ExperimentalStdlibApi - fun insert(feeds: List, updateDecsync: Boolean = true): List { - for (feed in feeds) { - if (feed.isGroup && feed.link.isEmpty()) { - feed.link = "catID%05".format(Random().nextInt(100000)) - } - } - if (updateDecsync && App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { - val entries = mutableListOf() - for (feed in feeds) { - if (feed.isGroup) { - entries.add(Decsync.EntryWithPath(listOf("categories", "names"), JsonPrimitive(feed.link), JsonPrimitive(feed.title))) - } else { - entries.add(Decsync.EntryWithPath(listOf("feeds", "subscriptions"), JsonPrimitive(feed.link), JsonPrimitive(true))) - if (feed.title != null) { - entries.add(Decsync.EntryWithPath(listOf("feeds", "names"), JsonPrimitive(feed.link), JsonPrimitive(feed.title))) - } - feed.groupId?.let { findById(it) }?.let { group -> - entries.add(Decsync.EntryWithPath(listOf("feeds", "categories"), JsonPrimitive(feed.link), JsonPrimitive(group.link))) - } - } - } - DecsyncUtils.getDecsync(App.context)?.setEntries(entries) - } - val ids = insertDao(*feeds.toTypedArray()) - if (App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { - for (feed in feeds) { - val extra = Extra() - if (feed.isGroup) { - DecsyncUtils.getDecsync(App.context)?.executeStoredEntry(listOf("categories", "names"), JsonPrimitive(feed.link), extra) - } else { - DecsyncUtils.getDecsync(App.context)?.executeStoredEntry(listOf("feeds", "names"), JsonPrimitive(feed.link), extra) - DecsyncUtils.getDecsync(App.context)?.executeStoredEntry(listOf("feeds", "categories"), JsonPrimitive(feed.link), extra) - } - } - } - return ids - } - - @ExperimentalStdlibApi - fun insert(feed: Feed, updateDecsync: Boolean = true): Long { - val ids = insert(listOf(feed), updateDecsync) - if (ids.size != 1) { - Log.w(TAG, "Wrong insertion for feed $feed") - return 0 - } - return ids[0] - } + abstract fun insert(vararg feeds: Feed): List @Update - protected abstract fun updateDao(vararg feeds: Feed) - - @ExperimentalStdlibApi - fun update(feeds: List, updateDecsync: Boolean = true) { - if (updateDecsync && App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { - val entries = mutableListOf() - for (feed in feeds) { - val origFeed = findById(feed.id) ?: continue - if (feed.isGroup) { - if (origFeed.title != feed.title) { - entries.add(Decsync.EntryWithPath(listOf("categories", "names"), JsonPrimitive(feed.link), JsonPrimitive(feed.title))) - } - } else { - var newLink = false - if (origFeed.link != feed.link) { - entries.add(Decsync.EntryWithPath(listOf("feeds", "subscriptions"), JsonPrimitive(origFeed.link), JsonPrimitive(false))) - entries.add(Decsync.EntryWithPath(listOf("feeds", "subscriptions"), JsonPrimitive(feed.link), JsonPrimitive(true))) - newLink = true - } - if (newLink || origFeed.title != feed.title) { - entries.add(Decsync.EntryWithPath(listOf("feeds", "names"), JsonPrimitive(feed.link), JsonPrimitive(feed.title))) - } - if (newLink || origFeed.groupId != feed.groupId) { - val group = feed.groupId?.let { findById(it) } - entries.add(Decsync.EntryWithPath(listOf("feeds", "categories"), JsonPrimitive(feed.link), JsonPrimitive(group?.link))) - } - } - } - DecsyncUtils.getDecsync(App.context)?.setEntries(entries) - } - updateDao(*feeds.toTypedArray()) - } - - @ExperimentalStdlibApi - fun update(feed: Feed, updateDecsync: Boolean = true) = update(listOf(feed), updateDecsync) + abstract fun update(vararg feeds: Feed) @Delete - protected abstract fun deleteDao(vararg feeds: Feed) - - @ExperimentalStdlibApi - fun delete(feeds: List, updateDecsync: Boolean = true) { - if (updateDecsync && App.context.getPrefBoolean(DECSYNC_ENABLED, false)) { - val entries = feeds.mapNotNull { feed -> - if (feed.isGroup) return@mapNotNull null - Decsync.Entry(JsonPrimitive(feed.link), JsonPrimitive(false)) - } - DecsyncUtils.getDecsync(App.context)?.setEntriesForPath(listOf("feeds", "subscriptions"), entries) - } - deleteDao(*feeds.toTypedArray()) - } - - @ExperimentalStdlibApi - fun delete(feed: Feed, updateDecsync: Boolean = true) = delete(listOf(feed), updateDecsync) - - @ExperimentalStdlibApi - fun deleteById(id: Long, updateDecsync: Boolean = true) { - val feed = findById(id) ?: return - delete(feed, updateDecsync) - } - - @ExperimentalStdlibApi - fun deleteByLink(link: String, updateDecsync: Boolean = true) { - val feed = findByLink(link) ?: return - delete(feed, updateDecsync) - } + abstract fun delete(vararg feeds: Feed) } \ No newline at end of file diff --git a/app/src/main/java/net/frju/flym/data/entities/DecsyncArticle.kt b/app/src/main/java/net/frju/flym/data/entities/DecsyncArticle.kt new file mode 100644 index 000000000..62cd0f6c1 --- /dev/null +++ b/app/src/main/java/net/frju/flym/data/entities/DecsyncArticle.kt @@ -0,0 +1,22 @@ +package net.frju.flym.data.entities + +import org.decsync.library.items.Rss +import java.util.* + +@ExperimentalStdlibApi +data class DecsyncArticle( + val uri: String, + val read: Boolean, + val favorite: Boolean, + val publicationDate: Date +) { + fun getRssArticle(): Rss.Article { + val time = publicationDate.time + val date = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + date.timeInMillis = time + val year = date.get(Calendar.YEAR) + val month = date.get(Calendar.MONTH) + 1 + val day = date.get(Calendar.DAY_OF_MONTH) + return Rss.Article(uri, read, favorite, year, month, day) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/frju/flym/data/entities/DecsyncFeed.kt b/app/src/main/java/net/frju/flym/data/entities/DecsyncFeed.kt new file mode 100644 index 000000000..5644ec451 --- /dev/null +++ b/app/src/main/java/net/frju/flym/data/entities/DecsyncFeed.kt @@ -0,0 +1,31 @@ +package net.frju.flym.data.entities + +import net.frju.flym.App +import org.decsync.library.items.Rss + +@ExperimentalStdlibApi +data class DecsyncFeed( + val feedLink: String, + val feedTitle: String?, + val groupId: Long? +) { + fun getRssFeed(): Rss.Feed { + return Rss.Feed(feedLink, feedTitle, groupId) { + groupId?.let { App.db.feedDao().findById(it)?.link } + } + } +} + +@ExperimentalStdlibApi +data class DecsyncCategory( + val feedLink: String, + val feedTitle: String +) { + fun getRssCategory() : Rss.Category { + return Rss.Category(feedLink, feedTitle, null) { + // We do not support nested categories + // Only changes are detected, so always giving the default value of null is fine + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/frju/flym/data/entities/Entry.kt b/app/src/main/java/net/frju/flym/data/entities/Entry.kt index f9fceac0d..f94beb2df 100644 --- a/app/src/main/java/net/frju/flym/data/entities/Entry.kt +++ b/app/src/main/java/net/frju/flym/data/entities/Entry.kt @@ -35,8 +35,6 @@ import net.frju.flym.utils.sha1 import org.decsync.library.Decsync import java.util.* -private const val TAG = "Entry" - @Parcelize @Entity(tableName = "entries", indices = [(Index(value = ["feedId"])), (Index(value = ["link"], unique = true)), (Index(value = ["uri"], unique = true))], @@ -66,40 +64,6 @@ data class Entry(@PrimaryKey DateFormat.getMediumDateFormat(context).format(publicationDate) + ' ' + DateFormat.getTimeFormat(context).format(publicationDate) } - - @ExperimentalStdlibApi - fun getDecsyncEntry(type: String, value: Boolean): Decsync.EntryWithPath? { - if (publicationDate == fetchDate) { - Log.w(TAG, "Unknown publication date for entry $this") - return null - } - val path = getDecsyncPath(type) - val key = uri ?: run { - Log.w(TAG, "Unknown uri for entry $this") - return null - } - return Decsync.EntryWithPath(path, JsonPrimitive(key), JsonPrimitive(value)) - } - - @ExperimentalStdlibApi - fun getDecsyncStoredEntry(type: String): Decsync.StoredEntry? { - val path = getDecsyncPath(type) - val key = uri ?: run { - Log.w(TAG, "Unknown uri for entry $this") - return null - } - return Decsync.StoredEntry(path, JsonPrimitive(key)) - } - - private fun getDecsyncPath(type: String): List { - val time = publicationDate.time - val date = Calendar.getInstance(TimeZone.getTimeZone("UTC")) - date.timeInMillis = time - val year = "%04d".format(date.get(Calendar.YEAR)) - val month = "%02d".format(date.get(Calendar.MONTH) + 1) - val day = "%02d".format(date.get(Calendar.DAY_OF_MONTH)) - return listOf("articles", type, year, month, day) - } } fun SyndEntry.toDbFormat(context: Context, feedId: Long): Entry { diff --git a/app/src/main/java/net/frju/flym/service/AutoRefreshJobService.kt b/app/src/main/java/net/frju/flym/service/AutoRefreshJobService.kt index 10d9b4ced..a77f3e17f 100644 --- a/app/src/main/java/net/frju/flym/service/AutoRefreshJobService.kt +++ b/app/src/main/java/net/frju/flym/service/AutoRefreshJobService.kt @@ -24,6 +24,7 @@ import android.app.job.JobService import android.content.ComponentName import android.content.Context import android.os.Build +import kotlinx.coroutines.ObsoleteCoroutinesApi import net.frju.flym.data.utils.PrefConstants import net.frju.flym.utils.getPrefBoolean import net.frju.flym.utils.getPrefString @@ -65,6 +66,7 @@ class AutoRefreshJobService : JobService() { } @ExperimentalStdlibApi + @ObsoleteCoroutinesApi override fun onStartJob(params: JobParameters): Boolean { if (!ignoreNextJob && !getPrefBoolean(PrefConstants.IS_REFRESHING, false)) { doAsync { diff --git a/app/src/main/java/net/frju/flym/service/FetcherService.kt b/app/src/main/java/net/frju/flym/service/FetcherService.kt index a26b6bc8e..235f6377a 100644 --- a/app/src/main/java/net/frju/flym/service/FetcherService.kt +++ b/app/src/main/java/net/frju/flym/service/FetcherService.kt @@ -33,7 +33,7 @@ import androidx.core.app.NotificationCompat import androidx.core.text.HtmlCompat import com.rometools.rome.io.SyndFeedInput import com.rometools.rome.io.XmlReader -import kotlinx.serialization.json.JsonPrimitive +import kotlinx.coroutines.ObsoleteCoroutinesApi import net.dankito.readability4j.extended.Readability4JExtended import net.fred.feedex.R import net.frju.flym.App @@ -51,7 +51,6 @@ import okhttp3.OkHttpClient import okhttp3.Request import okio.buffer import okio.sink -import org.decsync.library.Decsync import org.jetbrains.anko.* import org.jsoup.Jsoup import java.io.File @@ -59,7 +58,6 @@ import java.io.FileOutputStream import java.io.IOException import java.net.CookieManager import java.net.CookiePolicy -import java.util.* import java.util.concurrent.ExecutorCompletionService import java.util.concurrent.Executors import java.util.concurrent.TimeUnit @@ -82,7 +80,6 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { .build() const val FROM_AUTO_REFRESH = "FROM_AUTO_REFRESH" - const val FROM_INIT_SYNC = "FROM_INIT_SYNC" const val ACTION_REFRESH_FEEDS = "net.frju.flym.REFRESH" const val ACTION_MOBILIZE_FEEDS = "net.frju.flym.MOBILIZE_FEEDS" @@ -103,7 +100,8 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { .build()) @ExperimentalStdlibApi - fun fetch(context: Context, isFromAutoRefresh: Boolean, action: String, feedId: Long = 0L, isFromInitSync: Boolean = false) { + @ObsoleteCoroutinesApi + fun fetch(context: Context, isFromAutoRefresh: Boolean, action: String, feedId: Long = 0L) { if (context.getPrefBoolean(PrefConstants.IS_REFRESHING, false)) { return } @@ -139,55 +137,13 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { deleteOldEntries(unreadEntriesKeepDate, 0) COOKIE_MANAGER.cookieStore.removeAll() // Cookies are important for some sites, but we clean them each times - // We need to use the more recent date in order to be sure to not see old entries again - val acceptMinDate = max(readEntriesKeepDate, unreadEntriesKeepDate) - if (context.getPrefBoolean(PrefConstants.DECSYNC_ENABLED, false)) { - val extra = Extra() - if (isFromInitSync) { - // Give old groups a category ID - val updatedGroups = App.db.feedDao().all.mapNotNull { feed -> - if (feed.isGroup && feed.link.isEmpty()) { - feed.link = "catID%05".format(Random().nextInt(100000)) - feed - } else { - null - } - } - App.db.feedDao().update(updatedGroups, false) - - // Initialize DecSync and subscribe to its feeds - DecsyncUtils.getDecsync(context)?.initStoredEntries() - DecsyncUtils.getDecsync(context)?.executeStoredEntriesForPathExact(listOf("feeds", "subscriptions"), extra) - - // Write subscriptions, categories and read and marked articles to DecSync - // It doesn't matter some are already there, as libdecsync will ignore these - val entries = mutableListOf() - for (feed in App.db.feedDao().all) { - if (feed.isGroup) { - entries.add(Decsync.EntryWithPath(listOf("categories", "names"), JsonPrimitive(feed.link), JsonPrimitive(feed.title))) - } else { - entries.add(Decsync.EntryWithPath(listOf("feeds", "subscriptions"), JsonPrimitive(feed.link), JsonPrimitive(true))) - if (feed.title != null) { - entries.add(Decsync.EntryWithPath(listOf("feeds", "names"), JsonPrimitive(feed.link), JsonPrimitive(feed.title))) - } - feed.groupId?.let { App.db.feedDao().findById(it) }?.let { group -> - entries.add(Decsync.EntryWithPath(listOf("feeds", "categories"), JsonPrimitive(feed.link), JsonPrimitive(group.link))) - } - } - } - for (id in App.db.entryDao().readIds) { - entries.add(App.db.entryDao().getReadMarkEntry(id, "read", true) ?: continue) - } - for (id in App.db.entryDao().favoriteIds) { - entries.add(App.db.entryDao().getReadMarkEntry(id, "marked", true) ?: continue) - } - DecsyncUtils.getDecsync(context)?.setEntries(entries) - } else { - DecsyncUtils.getDecsync(context)?.executeAllNewEntries(extra) - } + DecsyncUtils.withDecsync(context) { executeAllNewEntries(Extra()) } } + // We need to use the more recent date in order to be sure to not see old entries again + val acceptMinDate = max(readEntriesKeepDate, unreadEntriesKeepDate) + var newCount = 0 if (feedId == 0L || App.db.feedDao().findById(feedId)!!.isGroup) { newCount = refreshFeeds(feedId, acceptMinDate) @@ -406,7 +362,6 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { } } - @ExperimentalStdlibApi private fun refreshFeeds(feedId: Long, acceptMinDate: Long): Int { val executor = Executors.newFixedThreadPool(THREAD_NUMBER) { r -> @@ -450,7 +405,6 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { return globalResult } - @ExperimentalStdlibApi private fun refreshFeed(feedId: Long, feedLink: String, acceptMinDate: Long): Int { val entries = mutableListOf() val entriesToInsert = mutableListOf() @@ -540,7 +494,7 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { } // Insert everything - App.db.entryDao().insert(entriesToInsert) + App.db.entryDao().insert(*entriesToInsert.toTypedArray()) if (App.db.feedDao().findById(feedId)?.retrieveFullText == true) { addEntriesToMobilize(entries.map { it.id }) @@ -618,6 +572,7 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { private val handler = Handler() @ExperimentalStdlibApi + @ObsoleteCoroutinesApi public override fun onHandleIntent(intent: Intent?) { if (intent == null) { // No intent, we quit return @@ -635,7 +590,6 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) { } val feedId = intent.getLongExtra(EXTRA_FEED_ID, 0L) - val isFromInitSync = intent.getBooleanExtra(FROM_INIT_SYNC, false) - fetch(this, isFromAutoRefresh, intent.action!!, feedId, isFromInitSync) + fetch(this, isFromAutoRefresh, intent.action!!, feedId) } } diff --git a/app/src/main/java/net/frju/flym/ui/discover/DiscoverActivity.kt b/app/src/main/java/net/frju/flym/ui/discover/DiscoverActivity.kt index 344718f83..4b166565a 100644 --- a/app/src/main/java/net/frju/flym/ui/discover/DiscoverActivity.kt +++ b/app/src/main/java/net/frju/flym/ui/discover/DiscoverActivity.kt @@ -39,7 +39,6 @@ class DiscoverActivity : AppCompatActivity(), FeedManagementInterface { private var searchInput: AutoCompleteTextView? = null - @ExperimentalStdlibApi override fun onCreate(savedInstanceState: Bundle?) { setupTheme() super.onCreate(savedInstanceState) @@ -59,7 +58,6 @@ class DiscoverActivity : AppCompatActivity(), FeedManagementInterface { savedInstanceState.putString(FeedSearchFragment.ARG_QUERY, searchInput?.text?.toString()) } - @ExperimentalStdlibApi private fun initSearchInputs() { var timer = Timer() searchInput = this.findViewById(R.id.et_search_input) @@ -135,7 +133,6 @@ class DiscoverActivity : AppCompatActivity(), FeedManagementInterface { searchInput?.setText(query) } - @ExperimentalStdlibApi override fun addFeed(view: View, title: String, link: String) { doAsync { val feedToAdd = Feed(link = link, title = title) @@ -146,7 +143,6 @@ class DiscoverActivity : AppCompatActivity(), FeedManagementInterface { } } - @ExperimentalStdlibApi override fun deleteFeed(view: View, feed: SearchFeedResult) { doAsync { App.db.feedDao().deleteByLink(feed.link) diff --git a/app/src/main/java/net/frju/flym/ui/entries/EntriesFragment.kt b/app/src/main/java/net/frju/flym/ui/entries/EntriesFragment.kt index 9318da76c..2bb1bf2f2 100644 --- a/app/src/main/java/net/frju/flym/ui/entries/EntriesFragment.kt +++ b/app/src/main/java/net/frju/flym/ui/entries/EntriesFragment.kt @@ -85,7 +85,6 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { } } - @ExperimentalStdlibApi var feed: Feed? = null set(value) { field = value @@ -96,7 +95,6 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { private val navigator: MainNavigator by lazy { activity as MainNavigator } - @ExperimentalStdlibApi private val adapter = EntryAdapter( displayThumbnails = context?.getPrefBoolean(PrefConstants.DISPLAY_THUMBNAILS, true) == true, globalClickListener = { entryWithFeed -> @@ -146,7 +144,6 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { setHasOptionsMenu(true) } - @ExperimentalStdlibApi override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) @@ -228,7 +225,6 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { } } - @ExperimentalStdlibApi private fun initDataObservers() { isDesc = context?.getPrefBoolean(PrefConstants.SORT_ORDER, true)!! entryIdsLiveData?.removeObservers(viewLifecycleOwner) @@ -293,7 +289,6 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { }) } - @ExperimentalStdlibApi override fun onStart() { super.onStart() context?.registerOnPrefChangeListener(prefListener) @@ -406,7 +401,6 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { context?.unregisterOnPrefChangeListener(prefListener) } - @ExperimentalStdlibApi override fun onSaveInstanceState(outState: Bundle) { outState.putParcelable(STATE_FEED, feed) outState.putString(STATE_SELECTED_ENTRY_ID, adapter.selectedEntryId) @@ -416,7 +410,6 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { super.onSaveInstanceState(outState) } - @ExperimentalStdlibApi private fun setupRecyclerView() { recycler_view.setHasFixedSize(true) @@ -509,7 +502,6 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { }) } - @ExperimentalStdlibApi private fun startRefresh() { if (context?.getPrefBoolean(PrefConstants.IS_REFRESHING, false) == false) { if (feed?.id != Feed.ALL_ENTRIES_ID) { @@ -524,7 +516,6 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { refresh_layout.postDelayed({ refreshSwipeProgress() }, 500) } - @ExperimentalStdlibApi private fun setupTitle() { activity?.toolbar?.apply { if (feed == null || feed?.id == Feed.ALL_ENTRIES_ID) { @@ -558,7 +549,6 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { return location[1] < resources.displayMetrics.heightPixels } - @ExperimentalStdlibApi override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) @@ -611,7 +601,6 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { }) } - @ExperimentalStdlibApi override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_entries__share -> { @@ -632,7 +621,6 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { return true } - @ExperimentalStdlibApi fun setSelectedEntryId(selectedEntryId: String) { adapter.selectedEntryId = selectedEntryId } diff --git a/app/src/main/java/net/frju/flym/ui/entrydetails/EntryDetailsFragment.kt b/app/src/main/java/net/frju/flym/ui/entrydetails/EntryDetailsFragment.kt index 0b1d808c8..b8eeb3c70 100644 --- a/app/src/main/java/net/frju/flym/ui/entrydetails/EntryDetailsFragment.kt +++ b/app/src/main/java/net/frju/flym/ui/entrydetails/EntryDetailsFragment.kt @@ -111,7 +111,6 @@ class EntryDetailsFragment : Fragment() { entry_view.destroy() } - @ExperimentalStdlibApi override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) @@ -190,7 +189,6 @@ class EntryDetailsFragment : Fragment() { setEntry(arguments?.getString(ARG_ENTRY_ID)!!, arguments?.getStringArrayList(ARG_ALL_ENTRIES_IDS)!!) } - @ExperimentalStdlibApi private fun initDataObservers() { isMobilizingLiveData?.removeObservers(viewLifecycleOwner) refresh_layout.isRefreshing = false @@ -225,7 +223,6 @@ class EntryDetailsFragment : Fragment() { }) } - @ExperimentalStdlibApi private fun setupToolbar() { toolbar.apply { entryWithFeed?.let { entryWithFeed -> @@ -300,7 +297,6 @@ class EntryDetailsFragment : Fragment() { } } - @ExperimentalStdlibApi private fun switchFullTextMode() { // Enable this to test new manual mobilization // doAsync { @@ -341,7 +337,6 @@ class EntryDetailsFragment : Fragment() { } } - @ExperimentalStdlibApi fun setEntry(entryId: String, allEntryIds: List) { this.entryId = entryId this.allEntryIds = allEntryIds diff --git a/app/src/main/java/net/frju/flym/ui/feeds/FeedListEditFragment.kt b/app/src/main/java/net/frju/flym/ui/feeds/FeedListEditFragment.kt index b62953994..e1f9b36f5 100644 --- a/app/src/main/java/net/frju/flym/ui/feeds/FeedListEditFragment.kt +++ b/app/src/main/java/net/frju/flym/ui/feeds/FeedListEditFragment.kt @@ -42,7 +42,6 @@ class FeedListEditFragment : Fragment() { private val feedGroups = mutableListOf() private val feedAdapter = EditFeedAdapter(feedGroups) - @ExperimentalStdlibApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val view = inflater.inflate(R.layout.fragment_feed_list_edit, container, false) @@ -116,7 +115,6 @@ class FeedListEditFragment : Fragment() { inflater.inflate(R.menu.menu_fragment_feed_list_edit, menu) } - @ExperimentalStdlibApi override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.add_group -> { @@ -148,7 +146,6 @@ class FeedListEditFragment : Fragment() { return false } - @ExperimentalStdlibApi private fun changeItemPriority(fromFeed: Feed, newDisplayPriority: Int) { fromFeed.displayPriority = newDisplayPriority diff --git a/app/src/main/java/net/frju/flym/ui/main/MainActivity.kt b/app/src/main/java/net/frju/flym/ui/main/MainActivity.kt index 726f8574b..bfe477f7c 100644 --- a/app/src/main/java/net/frju/flym/ui/main/MainActivity.kt +++ b/app/src/main/java/net/frju/flym/ui/main/MainActivity.kt @@ -47,6 +47,7 @@ import kotlinx.android.synthetic.main.activity_main.* import kotlinx.android.synthetic.main.dialog_edit_feed.view.* import kotlinx.android.synthetic.main.fragment_entries.* import kotlinx.android.synthetic.main.view_main_drawer_header.* +import kotlinx.coroutines.ObsoleteCoroutinesApi import net.fred.feedex.R import net.frju.flym.App import net.frju.flym.data.entities.Feed @@ -102,6 +103,7 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { private val feedAdapter = FeedAdapter(feedGroups) @ExperimentalStdlibApi + @ObsoleteCoroutinesApi override fun onCreate(savedInstanceState: Bundle?) { setupNoActionBarTheme() @@ -300,8 +302,7 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { .setAction(FetcherService.ACTION_REFRESH_FEEDS) .putExtra(FetcherService.FROM_AUTO_REFRESH, true)) } else if (getPrefBoolean(PrefConstants.DECSYNC_ENABLED, false)) { - val extra = Extra() - DecsyncUtils.getDecsync(this)?.executeAllNewEntries(extra, true) + DecsyncUtils.withDecsync(this) { executeAllNewEntries(Extra(), true) } } AutoRefreshJobService.initAutoRefresh(this) @@ -340,7 +341,6 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { } } - @ExperimentalStdlibApi override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) @@ -349,7 +349,6 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { handleImplicitIntent(intent) } - @ExperimentalStdlibApi private fun handleImplicitIntent(intent: Intent?) { // Has to be called on onStart (when the app is closed) and on onNewIntent (when the app is in the background) @@ -438,7 +437,6 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { } } - @ExperimentalStdlibApi override fun goToEntriesList(feed: Feed?) { clearDetails() containers_layout.state = MainNavigator.State.TWO_COLUMNS_EMPTY @@ -459,7 +457,6 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { override fun goToFeedSearch() = DiscoverActivity.newInstance(this) - @ExperimentalStdlibApi override fun goToEntryDetails(entryId: String, allEntryIds: List) { closeKeyboard() @@ -483,7 +480,6 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { } } - @ExperimentalStdlibApi override fun setSelectedEntryId(selectedEntryId: String) { val listFragment = supportFragmentManager.findFragmentById(R.id.frame_master) as EntriesFragment listFragment.setSelectedEntryId(selectedEntryId) @@ -497,7 +493,6 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { startActivity() } - @ExperimentalStdlibApi private fun openInBrowser(entryId: String) { doAsync { App.db.entryDao().findByIdWithFeed(entryId)?.entry?.link?.let { url -> @@ -553,7 +548,6 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { startActivityForResult(intent, WRITE_OPML_REQUEST_CODE) } - @ExperimentalStdlibApi override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) { super.onActivityResult(requestCode, resultCode, resultData) @@ -564,7 +558,6 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { } } - @ExperimentalStdlibApi @AfterPermissionGranted(AUTO_IMPORT_OPML_REQUEST_CODE) private fun autoImportOpml() { if (!EasyPermissions.hasPermissions(this, *NEEDED_PERMS)) { @@ -578,7 +571,6 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { } } - @ExperimentalStdlibApi private fun importOpml(uri: Uri) { doAsync { try { @@ -612,7 +604,6 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { } } - @ExperimentalStdlibApi private fun parseOpml(opmlReader: Reader) { var genId = 1L val feedList = mutableListOf() @@ -650,7 +641,7 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger { } if (feedList.isNotEmpty()) { - App.db.feedDao().insert(feedList) + App.db.feedDao().insert(*feedList.toTypedArray()) } } diff --git a/app/src/main/java/net/frju/flym/ui/settings/SettingsFragment.kt b/app/src/main/java/net/frju/flym/ui/settings/SettingsFragment.kt index 941d6ae13..834596124 100644 --- a/app/src/main/java/net/frju/flym/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/net/frju/flym/ui/settings/SettingsFragment.kt @@ -28,6 +28,7 @@ import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import com.nononsenseapps.filepicker.FilePickerActivity import com.nononsenseapps.filepicker.Utils +import kotlinx.coroutines.ObsoleteCoroutinesApi import net.fred.feedex.R import net.frju.flym.data.utils.PrefConstants.DECSYNC_ENABLED import net.frju.flym.data.utils.PrefConstants.DECSYNC_FILE @@ -55,7 +56,6 @@ class SettingsFragment : PreferenceFragmentCompat() { true } - @ExperimentalStdlibApi override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.settings, rootKey) @@ -116,6 +116,7 @@ class SettingsFragment : PreferenceFragmentCompat() { } @ExperimentalStdlibApi + @ObsoleteCoroutinesApi override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requireContext().getPrefBoolean(DECSYNC_USE_SAF, false)) { @@ -141,7 +142,6 @@ class SettingsFragment : PreferenceFragmentCompat() { } } - @ExperimentalStdlibApi override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { when (requestCode) { PERMISSIONS_REQUEST_DECSYNC -> if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { diff --git a/app/src/main/java/net/frju/flym/utils/DecsyncListeners.kt b/app/src/main/java/net/frju/flym/utils/DecsyncListeners.kt new file mode 100644 index 000000000..d6608298b --- /dev/null +++ b/app/src/main/java/net/frju/flym/utils/DecsyncListeners.kt @@ -0,0 +1,125 @@ +package net.frju.flym.utils + +import android.util.Log +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import net.frju.flym.App +import net.frju.flym.data.entities.Feed +import org.decsync.library.Decsync + +private const val TAG = "DecsyncListeners" + +@ExperimentalStdlibApi +object DecsyncListeners { + fun readListener(path: List, entry: Decsync.Entry, extra: Extra) { + Log.d(TAG, "Execute read entry $entry") + val uri = entry.key.jsonPrimitive.content + val value = entry.value.jsonPrimitive.boolean + val id = App.db.entryDao().idForUri(uri) ?: run { + Log.i(TAG, "Unknown article $uri") + return + } + if (value) { + App.db.entryDao().markAsRead(listOf(id)) + } else { + App.db.entryDao().markAsUnread(listOf(id)) + } + } + + fun markedListener(path: List, entry: Decsync.Entry, extra: Extra) { + Log.d(TAG, "Execute mark entry $entry") + val uri = entry.key.jsonPrimitive.content + val value = entry.value.jsonPrimitive.boolean + val id = App.db.entryDao().idForUri(uri) ?: run { + Log.i(TAG, "Unknown article $uri") + return + } + if (value) { + App.db.entryDao().markAsFavorite(id) + } else { + App.db.entryDao().markAsNotFavorite(id) + } + } + + fun subscriptionsListener(path: List, entry: Decsync.Entry, extra: Extra) { + Log.d(TAG, "Execute subscribe entry $entry") + val link = entry.key.jsonPrimitive.content + val subscribed = entry.value.jsonPrimitive.boolean + if (subscribed) { + if (App.db.feedDao().findByLink(link) == null) { + App.db.feedDao().insert(Feed(link = link)) + } + } else { + val feed = App.db.feedDao().findByLink(link) ?: run { + Log.i(TAG, "Unknown feed $link") + return + } + val groupId = feed.groupId + App.db.feedDao().delete(feed) + removeGroupIfEmpty(groupId) + } + } + + fun feedNamesListener(path: List, entry: Decsync.Entry, extra: Extra) { + Log.d(TAG, "Execute rename entry $entry") + val link = entry.key.jsonPrimitive.content + val name = entry.value.jsonPrimitive.content + val feed = App.db.feedDao().findByLink(link) ?: run { + Log.i(TAG, "Unknown feed $link") + return + } + if (feed.title != name) { + feed.title = name + App.db.feedDao().update(feed) + } + } + + fun categoriesListener(path: List, entry: Decsync.Entry, extra: Extra) { + Log.d(TAG, "Execute move entry $entry") + val link = entry.key.jsonPrimitive.content + val catId = entry.value.jsonPrimitive.contentOrNull + val feed = App.db.feedDao().findByLink(link) ?: run { + Log.i(TAG, "Unknown feed $link") + return + } + val groupId = catId?.let { + App.db.feedDao().findByLink(catId)?.id ?: run { + val group = Feed(link = catId, title = catId, isGroup = true) + App.db.feedDao().insert(group)[0] + } + } + if (feed.groupId != groupId) { + val oldGroupId = feed.groupId + feed.groupId = groupId + App.db.feedDao().update(feed) + removeGroupIfEmpty(oldGroupId) + } + } + + fun categoryNamesListener(path: List, entry: Decsync.Entry, extra: Extra) { + Log.d(TAG, "Execute category rename entry $entry") + val catId = entry.key.jsonPrimitive.content + val name = entry.value.jsonPrimitive.content + val group = App.db.feedDao().findByLink(catId) ?: run { + Log.i(TAG, "Unknown category $catId") + return + } + if (group.title != name) { + group.title = name + App.db.feedDao().update(group) + } + } + + fun categoryParentsListener(path: List, entry: Decsync.Entry, extra: Extra) { + Log.i(TAG, "Nested categories are not supported") + } + + private fun removeGroupIfEmpty(groupId: Long?) { + if (groupId == null) return + if (App.db.feedDao().allFeedsInGroup(groupId).isEmpty()) { + val group = App.db.feedDao().findById(groupId) ?: return + App.db.feedDao().delete(group) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/frju/flym/utils/DecsyncUtils.kt b/app/src/main/java/net/frju/flym/utils/DecsyncUtils.kt index 9de3c97df..0e6865038 100644 --- a/app/src/main/java/net/frju/flym/utils/DecsyncUtils.kt +++ b/app/src/main/java/net/frju/flym/utils/DecsyncUtils.kt @@ -9,18 +9,16 @@ import android.os.Build import android.os.Environment import android.util.Log import androidx.core.app.NotificationCompat -import kotlinx.serialization.json.boolean -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonPrimitive +import kotlinx.coroutines.ObsoleteCoroutinesApi import net.fred.feedex.R import net.frju.flym.App -import net.frju.flym.data.entities.Feed import net.frju.flym.data.utils.PrefConstants.DECSYNC_ENABLED import net.frju.flym.data.utils.PrefConstants.DECSYNC_FILE import net.frju.flym.data.utils.PrefConstants.DECSYNC_USE_SAF import net.frju.flym.data.utils.PrefConstants.UPDATE_FORCES_SAF import net.frju.flym.service.FetcherService import org.decsync.library.Decsync +import org.decsync.library.DecsyncChannel import org.decsync.library.DecsyncPrefUtils import org.decsync.library.getAppId import org.jetbrains.anko.notificationManager @@ -34,177 +32,83 @@ private const val ERROR_NOTIFICATION_ID = 1 class Extra @ExperimentalStdlibApi +@ObsoleteCoroutinesApi object DecsyncUtils { - private var mDecsync: Decsync? = null - - private fun getNewDecsync(context: Context): Decsync { - val decsync = if (context.getPrefBoolean(DECSYNC_USE_SAF, false)) { - val decsyncDir = DecsyncPrefUtils.getDecsyncDir(context) ?: throw Exception(context.getString(R.string.settings_decsync_dir_not_configured)) - Decsync(context, decsyncDir, "rss", null, ownAppId) - } else { - val decsyncDir = File(context.getPrefString(DECSYNC_FILE, defaultDecsyncDir)) - Decsync(decsyncDir, "rss", null, ownAppId) - } - decsync.addListener(listOf("articles", "read"), ::readListener) - decsync.addListener(listOf("articles", "marked"), ::markedListener) - decsync.addListener(listOf("feeds", "subscriptions"), ::subscriptionsListener) - decsync.addListener(listOf("feeds", "names"), ::feedNamesListener) - decsync.addListener(listOf("feeds", "categories"), ::categoriesListener) - decsync.addListener(listOf("categories", "names"), ::categoryNamesListener) - decsync.addListener(listOf("categories", "parents"), ::categoryParentsListener) - return decsync - } - - fun getDecsync(context: Context): Decsync? { - if (mDecsync == null && context.getPrefBoolean(DECSYNC_ENABLED, false)) { + private val decsyncChannel = object: DecsyncChannel() { + override fun isDecsyncEnabled(context: Context): Boolean { + if (!context.getPrefBoolean(DECSYNC_ENABLED, false)) return false if (Build.VERSION.SDK_INT >= 29 && !Environment.isExternalStorageLegacy() && !context.getPrefBoolean(DECSYNC_USE_SAF, false)) { context.putPrefBoolean(DECSYNC_ENABLED, false) context.putPrefBoolean(DECSYNC_USE_SAF, true) context.putPrefBoolean(UPDATE_FORCES_SAF, true) - return null - } - try { - mDecsync = getNewDecsync(context) - } catch (e: Exception) { - Log.e(TAG, "", e) - context.putPrefBoolean(DECSYNC_ENABLED, false) - - val channelId = "channel_error" - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - channelId, - context.getString(R.string.channel_error_name), - NotificationManager.IMPORTANCE_DEFAULT - ) - context.notificationManager.createNotificationChannel(channel) - } - val notification = NotificationCompat.Builder(context, channelId) - .setSmallIcon(R.drawable.ic_statusbar_rss) - .setLargeIcon( - BitmapFactory.decodeResource( - context.resources, - R.mipmap.ic_launcher - ) - ) - .setContentTitle(context.getString(R.string.decsync_disabled)) - .setContentText(e.localizedMessage) - .build() - context.notificationManager.notify(ERROR_NOTIFICATION_ID, notification) + return false } + return true } - return mDecsync - } - - fun initSync(context: Context) { - mDecsync = null - context.startService(Intent(context, FetcherService::class.java) - .setAction(FetcherService.ACTION_REFRESH_FEEDS) - .putExtra(FetcherService.FROM_INIT_SYNC, true)) - } - - private fun readListener(path: List, entry: Decsync.Entry, extra: Extra) { - Log.d(TAG, "Execute read entry $entry") - val uri = entry.key.jsonPrimitive.content - val value = entry.value.jsonPrimitive.boolean - val id = App.db.entryDao().idForUri(uri) ?: run { - Log.i(TAG, "Unknown article $uri") - return - } - if (value) { - App.db.entryDao().markAsRead(listOf(id), false) - } else { - App.db.entryDao().markAsUnread(listOf(id), false) - } - } - private fun markedListener(path: List, entry: Decsync.Entry, extra: Extra) { - Log.d(TAG, "Execute mark entry $entry") - val uri = entry.key.jsonPrimitive.content - val value = entry.value.jsonPrimitive.boolean - val id = App.db.entryDao().idForUri(uri) ?: run { - Log.i(TAG, "Unknown article $uri") - return - } - if (value) { - App.db.entryDao().markAsFavorite(id, false) - } else { - App.db.entryDao().markAsNotFavorite(id, false) - } - } - - private fun subscriptionsListener(path: List, entry: Decsync.Entry, extra: Extra) { - Log.d(TAG, "Execute subscribe entry $entry") - val link = entry.key.jsonPrimitive.content - val subscribed = entry.value.jsonPrimitive.boolean - if (subscribed) { - if (App.db.feedDao().findByLink(link) == null) { - App.db.feedDao().insert(Feed(link = link), false) + override fun getNewDecsync(context: Context): Decsync { + val decsync = if (context.getPrefBoolean(DECSYNC_USE_SAF, false)) { + val decsyncDir = DecsyncPrefUtils.getDecsyncDir(context) ?: throw Exception(context.getString(R.string.settings_decsync_dir_not_configured)) + Decsync(context, decsyncDir, "rss", null, ownAppId) + } else { + val decsyncDir = File(context.getPrefString(DECSYNC_FILE, defaultDecsyncDir)) + Decsync(decsyncDir, "rss", null, ownAppId) } - } else { - val feed = App.db.feedDao().findByLink(link) ?: run { - Log.i(TAG, "Unknown feed $link") - return - } - val groupId = feed.groupId - App.db.feedDao().delete(feed, false) - removeGroupIfEmpty(groupId) + decsync.addListener(listOf("articles", "read"), DecsyncListeners::readListener) + decsync.addListener(listOf("articles", "marked"), DecsyncListeners::markedListener) + decsync.addListener(listOf("feeds", "subscriptions"), DecsyncListeners::subscriptionsListener) + decsync.addListener(listOf("feeds", "names"), DecsyncListeners::feedNamesListener) + decsync.addListener(listOf("feeds", "categories"), DecsyncListeners::categoriesListener) + decsync.addListener(listOf("categories", "names"), DecsyncListeners::categoryNamesListener) + decsync.addListener(listOf("categories", "parents"), DecsyncListeners::categoryParentsListener) + return decsync } - } - private fun feedNamesListener(path: List, entry: Decsync.Entry, extra: Extra) { - Log.d(TAG, "Execute rename entry $entry") - val link = entry.key.jsonPrimitive.content - val name = entry.value.jsonPrimitive.content - val feed = App.db.feedDao().findByLink(link) ?: run { - Log.i(TAG, "Unknown feed $link") - return - } - feed.title = name - App.db.feedDao().update(feed) - } - - private fun categoriesListener(path: List, entry: Decsync.Entry, extra: Extra) { - Log.d(TAG, "Execute move entry $entry") - val link = entry.key.jsonPrimitive.content - val catId = entry.value.jsonPrimitive.contentOrNull - val feed = App.db.feedDao().findByLink(link) ?: run { - Log.i(TAG, "Unknown feed $link") - return - } - val oldGroupId = feed.groupId - val groupId = catId?.let { - App.db.feedDao().findByLink(catId)?.id ?: run { - val group = Feed(link = catId, title = catId, isGroup = true) - App.db.feedDao().insert(group, false) + override fun onException(context: Context, e: Exception) { + Log.e(TAG, "", e) + context.putPrefBoolean(DECSYNC_ENABLED, false) + + val channelId = "channel_error" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + channelId, + context.getString(R.string.channel_error_name), + NotificationManager.IMPORTANCE_DEFAULT + ) + context.notificationManager.createNotificationChannel(channel) } + val notification = NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_statusbar_rss) + .setLargeIcon( + BitmapFactory.decodeResource( + context.resources, + R.mipmap.ic_launcher + ) + ) + .setContentTitle(context.getString(R.string.decsync_disabled)) + .setContentText(e.localizedMessage) + .build() + context.notificationManager.notify(ERROR_NOTIFICATION_ID, notification) } - feed.groupId = groupId - App.db.feedDao().update(feed, false) - removeGroupIfEmpty(oldGroupId) } - private fun categoryNamesListener(path: List, entry: Decsync.Entry, extra: Extra) { - Log.d(TAG, "Execute category rename entry $entry") - val catId = entry.key.jsonPrimitive.content - val name = entry.value.jsonPrimitive.content - val group = App.db.feedDao().findByLink(catId) ?: run { - Log.i(TAG, "Unknown category $catId") - return - } - group.title = name - App.db.feedDao().update(group, false) + fun withDecsync(context: Context, action: Decsync.() -> Unit) { + decsyncChannel.withDecsync(context, action) } - private fun categoryParentsListener(path: List, entry: Decsync.Entry, extra: Extra) { - Log.i(TAG, "Nested categories are not supported") - } + fun initSync(context: Context) { + decsyncChannel.initSyncWith(context) { + // Initialize DecSync and subscribe to its feeds + initStoredEntries() + executeStoredEntriesForPathExact(listOf("feeds", "subscriptions"), Extra()) + + // Behaves like we just inserted everything in the database + App.initSync() - private fun removeGroupIfEmpty(groupId: Long?) { - if (groupId == null) return - if (App.db.feedDao().allFeedsInGroup(groupId).isEmpty()) { - App.db.feedDao().deleteById(groupId, false) + context.startService(Intent(context, FetcherService::class.java) + .setAction(FetcherService.ACTION_REFRESH_FEEDS)) } } } \ No newline at end of file From 86f2a38c35f86fc1e4771e387424d1d972ab505f Mon Sep 17 00:00:00 2001 From: Aldo Gunsing Date: Sat, 24 Oct 2020 22:51:07 +0200 Subject: [PATCH 4/6] Reset targetSdkVersion to 30 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 04b864922..fa7145658 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -8,11 +8,11 @@ androidExtensions { } android { - compileSdkVersion 29 + compileSdkVersion 30 defaultConfig { applicationId "net.frju.flym" minSdkVersion 21 - targetSdkVersion 29 // Allows for legacy storage on Android 11 + targetSdkVersion 30 versionCode 37 versionName "2.5.3" } From e3e028d6e6ce5521c032c80c6dca7caa26b515f4 Mon Sep 17 00:00:00 2001 From: Aldo Gunsing Date: Sat, 24 Oct 2020 23:12:50 +0200 Subject: [PATCH 5/6] Fix crash on empty feed name --- app/src/main/java/net/frju/flym/data/entities/Feed.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/net/frju/flym/data/entities/Feed.kt b/app/src/main/java/net/frju/flym/data/entities/Feed.kt index 862374b35..8eaf9979a 100644 --- a/app/src/main/java/net/frju/flym/data/entities/Feed.kt +++ b/app/src/main/java/net/frju/flym/data/entities/Feed.kt @@ -77,6 +77,7 @@ data class Feed( val letters = when { split.size >= 2 -> String(charArrayOf(split[0][0], split[1][0])) // first letter of first and second word + split.isEmpty() -> "" else -> split[0][0].toString() } From fa40c8b7673563dbb621b0a76c95c5400f669cad Mon Sep 17 00:00:00 2001 From: Aldo Gunsing Date: Sat, 24 Oct 2020 23:42:26 +0200 Subject: [PATCH 6/6] Update libdecsync --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index fa7145658..ffea305a1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -123,7 +123,7 @@ dependencies { implementation 'pub.devrel:easypermissions:3.0.0' implementation 'com.rometools:rome-opml:1.15.0' implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.0' - implementation 'org.decsync:libdecsync:1.8.0' + implementation 'org.decsync:libdecsync:1.8.1' implementation 'com.nononsenseapps:filepicker:4.1.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9' }