From 412420251f05552c449d52d16654746e269df321 Mon Sep 17 00:00:00 2001 From: Aldo Gunsing Date: Thu, 1 Oct 2020 20:56:44 +0200 Subject: [PATCH] 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 87bb68abee..1b41e3be69 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 36 versionName "2.5.2" } @@ -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 0000000000..0828d2ff55 --- /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 2d575e6470..16a355db57 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 6c22399cd2..042a7a493e 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 30384c0a4b..96b88baded 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 b85736284c..c6b1aae8bd 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 78c875ba4a..10d9b4ced4 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 28bb87dd29..c835672319 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 4b166565ac..344718f838 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 283cd28b51..32318c0f07 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) @@ -223,6 +226,7 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { } } + @ExperimentalStdlibApi private fun initDataObservers() { isDesc = context?.getPrefBoolean(PrefConstants.SORT_ORDER, true)!! entryIdsLiveData?.removeObservers(viewLifecycleOwner) @@ -287,6 +291,7 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { }) } + @ExperimentalStdlibApi override fun onStart() { super.onStart() context?.registerOnPrefChangeListener(prefListener) @@ -399,6 +404,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) @@ -408,6 +414,7 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { super.onSaveInstanceState(outState) } + @ExperimentalStdlibApi private fun setupRecyclerView() { recycler_view.setHasFixedSize(true) @@ -500,6 +507,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) { @@ -514,6 +522,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) { @@ -547,6 +556,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) @@ -599,6 +609,7 @@ class EntriesFragment : Fragment(R.layout.fragment_entries) { }) } + @ExperimentalStdlibApi override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_entries__share -> { @@ -619,6 +630,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 b8eeb3c708..0b1d808c8c 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 e1f9b36f50..b629539941 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 e0070facd1..726f8574b8 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 cbe2122774..941d6ae130 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 0000000000..9de3c97dfc --- /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 f1c1898c25..ba1452703e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -133,6 +133,18 @@ 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 b988d93bb0..2520310a2d 100644 --- a/app/src/main/res/xml/settings.xml +++ b/app/src/main/res/xml/settings.xml @@ -194,4 +194,27 @@ + + + + + + + + + + \ No newline at end of file