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