diff --git a/app/build.gradle b/app/build.gradle
index 4aa4932d9..ffea305a1 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -122,4 +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-json:1.0.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'
}
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">
+
+
+
+
+
: 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 d53964a30..d44e9df24 100644
--- a/app/src/main/java/net/frju/flym/data/AppDatabase.kt
+++ b/app/src/main/java/net/frju/flym/data/AppDatabase.kt
@@ -34,7 +34,7 @@ import net.frju.flym.data.entities.Task
import org.jetbrains.anko.doAsync
-@Database(entities = [Feed::class, Entry::class, Task::class], version = 3)
+@Database(entities = [Feed::class, Entry::class, Task::class], version = 4)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
@@ -71,10 +71,21 @@ abstract class AppDatabase : RoomDatabase() {
}
}
+ private val MIGRATION_3_4: Migration = object : Migration(3, 4) {
+ override fun migrate(database: SupportSQLiteDatabase) {
+ 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 = ''")
+ }
+ }
+ }
+
fun createDatabase(context: Context): AppDatabase {
return Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, DATABASE_NAME)
.addMigrations(MIGRATION_1_2)
.addMigrations(MIGRATION_2_3)
+ .addMigrations(MIGRATION_3_4)
.addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
@@ -101,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 7fe00a043..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,9 +25,12 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
+import net.frju.flym.data.entities.DecsyncArticle
import net.frju.flym.data.entities.Entry
import net.frju.flym.data.entities.EntryWithFeed
+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"
@@ -38,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
@@ -107,6 +114,12 @@ abstract class EntryDao {
@Query("SELECT COUNT(*) FROM $JOIN WHERE groupId IS :groupId AND read = 0 AND fetchDate > :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,12 +132,24 @@ 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)
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..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
@@ -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,13 +25,29 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
+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 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)"
@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
@@ -61,8 +78,11 @@ 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)
- abstract fun insert(vararg feeds: Feed)
+ abstract fun insert(vararg feeds: Feed): List
@Update
abstract fun update(vararg feeds: Feed)
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 30384c0a4..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
@@ -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,15 @@ 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.*
@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 +46,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,
@@ -64,11 +66,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 {
@@ -76,6 +78,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/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()
}
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..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
@@ -64,6 +65,8 @@ 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 28bb87dd2..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,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.coroutines.ObsoleteCoroutinesApi
import net.dankito.readability4j.extended.Readability4JExtended
import net.fred.feedex.R
import net.frju.flym.App
@@ -98,6 +99,8 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) {
.addHeader("accept", "*/*")
.build())
+ @ExperimentalStdlibApi
+ @ObsoleteCoroutinesApi
fun fetch(context: Context, isFromAutoRefresh: Boolean, action: String, feedId: Long = 0L) {
if (context.getPrefBoolean(PrefConstants.IS_REFRESHING, false)) {
return
@@ -134,6 +137,10 @@ 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
+ if (context.getPrefBoolean(PrefConstants.DECSYNC_ENABLED, false)) {
+ 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)
@@ -141,11 +148,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)
}
}
}
@@ -283,14 +290,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) {
@@ -315,9 +322,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)
}
}
@@ -376,7 +383,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)
}
@@ -398,30 +405,32 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) {
return globalResult
}
- 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
@@ -446,7 +455,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
@@ -485,9 +494,9 @@ class FetcherService : IntentService(FetcherService::class.java.simpleName) {
}
// Insert everything
- App.db.entryDao().insert(*(entriesToInsert.toTypedArray()))
+ App.db.entryDao().insert(*entriesToInsert.toTypedArray())
- if (feed.retrieveFullText) {
+ if (App.db.feedDao().findById(feedId)?.retrieveFullText == true) {
addEntriesToMobilize(entries.map { it.id })
}
@@ -562,6 +571,8 @@ 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
@@ -578,6 +589,7 @@ 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)
+ fetch(this, isFromAutoRefresh, intent.action!!, feedId)
}
}
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..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
@@ -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
@@ -46,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
@@ -62,28 +64,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 +102,8 @@ class MainActivity : AppCompatActivity(), MainNavigator, AnkoLogger {
private val feedGroups = mutableListOf()
private val feedAdapter = FeedAdapter(feedGroups)
+ @ExperimentalStdlibApi
+ @ObsoleteCoroutinesApi
override fun onCreate(savedInstanceState: Bundle?) {
setupNoActionBarTheme()
@@ -285,10 +276,33 @@ 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)) {
+ DecsyncUtils.withDecsync(this) { executeAllNewEntries(Extra(), true) }
}
AutoRefreshJobService.initAutoRefresh(this)
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..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
@@ -17,22 +17,40 @@
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 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
+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
@@ -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
+ @ObsoleteCoroutinesApi
+ 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())
+ }
+ }
+ }
+ }
+
+ 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/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
new file mode 100644
index 000000000..0e6865038
--- /dev/null
+++ b/app/src/main/java/net/frju/flym/utils/DecsyncUtils.kt
@@ -0,0 +1,114 @@
+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.coroutines.ObsoleteCoroutinesApi
+import net.fred.feedex.R
+import net.frju.flym.App
+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
+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
+@ObsoleteCoroutinesApi
+object DecsyncUtils {
+ 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 false
+ }
+ return true
+ }
+
+ 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)
+ }
+ 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
+ }
+
+ 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)
+ }
+ }
+
+ fun withDecsync(context: Context, action: Decsync.() -> Unit) {
+ decsyncChannel.withDecsync(context, action)
+ }
+
+ 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()
+
+ context.startService(Intent(context, FetcherService::class.java)
+ .setAction(FetcherService.ACTION_REFRESH_FEEDS))
+ }
+ }
+}
\ 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