From 3ab78a19bcf9fa9212f27869805ca653d6638bfb Mon Sep 17 00:00:00 2001 From: Ivan Kupalov Date: Mon, 14 Jan 2019 22:05:08 +0100 Subject: [PATCH] Caching toots (#809) * Initial timeline cache implementation * Fix build/DI errors for caching * Rename timeline entities tables. Add migration. Add DB scheme file. * Fix uniqueness problem, change offline strategy, improve mapping * Try to merge in new statuses, fix bottom loading, fix saving spans. * Fix reblogs IDs, fix inserting elements from top * Send one more request to get latest timeline statuses * Give Timeline placeholders string id. Rewrite Either in Kotlin * Initial placeholder implementation for caching * Fix crash on removing overlap statuses * Migrate counters to long * Remove unused counters. Add minimal TimelineDAOTest * Fix bug with placeholder ID * Update cache in response to events. Refactor TimelineCases * Fix crash, reduce number of placeholders * Fix crash, fix filtering, improve placeholder handling * Fix migration, add 8-9 migration test * Fix initial timeline update, remove more placeholders * Add cleanup for old statuses * Fix cleanup * Delete ExampleInstrumentedTest * Improve timeline UX regarding caching * Fix typos * Fix initial timeline update * Cleanup/fix initial timeline update * Workaround for weird behavior of first post on initial tl update. * Change counter types back to int * Clear timeline cache on logout * Fix loading when timeline is completely empty * Fix androidx migration issues * Fix tests * Apply caching feedback * Save account emojis to cache * Fix warnings and bugs --- app/build.gradle | 4 + .../11.json | 515 ++++++++++++++++++ .../tusky/ExampleInstrumentedTest.java | 26 - .../com/keylesspalace/tusky/MigrationsTest.kt | 64 +++ .../keylesspalace/tusky/TimelineDAOTest.kt | 217 ++++++++ .../com/keylesspalace/tusky/MainActivity.java | 5 + .../keylesspalace/tusky/TuskyApplication.java | 2 +- .../tusky/appstore/CacheUpdater.kt | 47 ++ .../keylesspalace/tusky/db/AppDatabase.java | 52 +- .../com/keylesspalace/tusky/db/TimelineDao.kt | 87 +++ .../tusky/db/TimelineStatusEntity.kt | 79 +++ .../keylesspalace/tusky/db/TootEntity.java | 10 +- .../keylesspalace/tusky/di/AppComponent.kt | 3 +- .../keylesspalace/tusky/di/NetworkModule.kt | 2 +- .../tusky/di/RepositoryModule.kt | 19 + .../com/keylesspalace/tusky/entity/Status.kt | 2 +- .../tusky/fragment/NotificationsFragment.java | 91 ++-- .../tusky/fragment/SearchFragment.kt | 82 ++- .../tusky/fragment/TimelineFragment.java | 459 +++++++++------- .../tusky/fragment/ViewThreadFragment.java | 50 +- .../tusky/network/MastodonApi.java | 14 +- .../tusky/network/TimelineCases.kt | 22 +- .../tusky/repository/TimelineRepository.kt | 404 ++++++++++++++ .../com/keylesspalace/tusky/util/Either.java | 125 ----- .../com/keylesspalace/tusky/util/Either.kt | 37 ++ .../keylesspalace/tusky/util/ListUtils.java | 9 +- .../tusky/view/EndlessOnScrollListener.java | 3 +- .../tusky/viewdata/AttachmentViewData.kt | 2 +- .../tusky/viewdata/StatusViewData.java | 15 +- 29 files changed, 1950 insertions(+), 497 deletions(-) create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/11.json create mode 100644 app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt create mode 100644 app/src/androidTest/java/com/keylesspalace/tusky/TimelineDAOTest.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/Either.kt diff --git a/app/build.gradle b/app/build.gradle index 86ca4598..66f917b1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -91,6 +91,7 @@ dependencies { implementation 'androidx.preference:preference:1.1.0-alpha02' implementation 'com.squareup.retrofit2:retrofit:2.5.0' implementation 'com.squareup.retrofit2:converter-gson:2.5.0' + implementation 'com.squareup.retrofit2:adapter-rxjava2:2.5.0' implementation 'com.squareup.picasso:picasso:2.5.2' implementation 'com.squareup.okhttp3:okhttp:3.12.0' implementation 'com.squareup.okhttp3:logging-interceptor:3.12.0' @@ -112,6 +113,7 @@ dependencies { //room implementation 'androidx.room:room-runtime:2.0.0' kapt 'androidx.room:room-compiler:2.0.0' + implementation 'android.arch.persistence.room:rxjava2:1.1.1' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" testImplementation 'junit:junit:4.12' implementation "com.google.dagger:dagger:$daggerVersion" @@ -124,6 +126,8 @@ dependencies { androidTestImplementation('androidx.test.espresso:espresso-core:3.1.0', { exclude group: 'com.android.support', module: 'support-annotations' }) + androidTestImplementation('android.arch.persistence.room:testing:1.1.1') + androidTestImplementation "androidx.test.ext:junit:1.1.0" debugImplementation 'im.dino:dbinspector:3.4.1@aar' implementation 'io.reactivex.rxjava2:rxjava:2.2.4' implementation 'io.reactivex.rxjava2:rxandroid:2.1.0' diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/11.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/11.json new file mode 100644 index 00000000..fe3fb45d --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/11.json @@ -0,0 +1,515 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "f5e93302cf53d4250e455b701bea102f", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `instance` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `instance` TEXT NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, \"f5e93302cf53d4250e455b701bea102f\")" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/keylesspalace/tusky/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/keylesspalace/tusky/ExampleInstrumentedTest.java index 3e54733f..e69de29b 100644 --- a/app/src/androidTest/java/com/keylesspalace/tusky/ExampleInstrumentedTest.java +++ b/app/src/androidTest/java/com/keylesspalace/tusky/ExampleInstrumentedTest.java @@ -1,26 +0,0 @@ -package com.keylesspalace.tusky; - -import android.content.Context; -import androidx.test.InstrumentationRegistry; -import androidx.test.runner.AndroidJUnit4; - -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.*; - -/** - * Instrumentation test, which will execute on an Android device. - * - * @see Testing documentation - */ -@RunWith(AndroidJUnit4.class) -public class ExampleInstrumentedTest { - @Test - public void useAppContext() throws Exception { - // Context of the app under test. - Context appContext = InstrumentationRegistry.getTargetContext(); - - assertEquals("com.keylesspalace.tusky", appContext.getPackageName()); - } -} diff --git a/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt b/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt new file mode 100644 index 00000000..b7f5c0b0 --- /dev/null +++ b/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt @@ -0,0 +1,64 @@ +package com.keylesspalace.tusky + +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.keylesspalace.tusky.db.AppDatabase +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +const val TEST_DB = "mirgation_test" + +@RunWith(AndroidJUnit4::class) +class MigrationsTest { + + @JvmField + @Rule + var helper: MigrationTestHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java.canonicalName, + FrameworkSQLiteOpenHelperFactory() + ) + + @Test + fun migrateTo11() { + val db = helper.createDatabase(TEST_DB, 10) + + val id = 1 + val domain = "domain.site" + val token = "token" + val active = true + val accountId = "accountId" + val username = "username" + val values = arrayOf(id, domain, token, active, accountId, username, "Display Name", + "https://picture.url", true, true, true, true, true, true, true, + true, "1000", "[]", "[{\"shortcode\": \"emoji\", \"url\": \"yes\"}]", 0, false, + false, true) + + db.execSQL("INSERT OR REPLACE INTO `AccountEntity`(`id`,`domain`,`accessToken`,`isActive`," + + "`accountId`,`username`,`displayName`,`profilePictureUrl`,`notificationsEnabled`," + + "`notificationsMentioned`,`notificationsFollowed`,`notificationsReblogged`," + + "`notificationsFavorited`,`notificationSound`,`notificationVibration`," + + "`notificationLight`,`lastNotificationId`,`activeNotifications`,`emojis`," + + "`defaultPostPrivacy`,`defaultMediaSensitivity`,`alwaysShowSensitiveMedia`," + + "`mediaPreviewEnabled`) " + + "VALUES (nullif(?, 0),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + values) + + db.close() + + val newDb = helper.runMigrationsAndValidate(TEST_DB, 11, true, AppDatabase.MIGRATION_10_11) + + val cursor = newDb.query("SELECT * FROM AccountEntity") + cursor.moveToFirst() + assertEquals(id, cursor.getInt(0)) + assertEquals(domain, cursor.getString(1)) + assertEquals(token, cursor.getString(2)) + assertEquals(active, cursor.getInt(3) != 0) + assertEquals(accountId, cursor.getString(4)) + assertEquals(username, cursor.getString(5)) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/keylesspalace/tusky/TimelineDAOTest.kt b/app/src/androidTest/java/com/keylesspalace/tusky/TimelineDAOTest.kt new file mode 100644 index 00000000..37c59546 --- /dev/null +++ b/app/src/androidTest/java/com/keylesspalace/tusky/TimelineDAOTest.kt @@ -0,0 +1,217 @@ +package com.keylesspalace.tusky + +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.AndroidJUnit4 +import com.keylesspalace.tusky.db.* +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.repository.TimelineRepository +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TimelineDAOTest { + private lateinit var timelineDao: TimelineDao + private lateinit var db: AppDatabase + + @Before + fun createDb() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() + timelineDao = db.timelineDao() + } + + @After + fun closeDb() { + db.close() + } + + @Test + fun insertGetStatus() { + val setOne = makeStatus() + val setTwo = makeStatus(statusId = 20, reblog = true) + val ignoredOne = makeStatus(statusId = 1) + val ignoredTwo = makeStatus(accountId = 2) + + for ((status, author, reblogger) in listOf(setOne, setTwo, ignoredOne, ignoredTwo)) { + timelineDao.insertInTransaction(status, author, reblogger) + } + + val resultsFromDb = timelineDao.getStatusesForAccount(setOne.first.timelineUserId, + maxId = "21", sinceId = ignoredOne.first.serverId, limit = 10) + .blockingGet() + + assertEquals(2, resultsFromDb.size) + for ((set, fromDb) in listOf(setTwo, setOne).zip(resultsFromDb)) { + val (status, author, reblogger) = set + assertEquals(status, fromDb.status) + assertEquals(author, fromDb.account) + assertEquals(reblogger, fromDb.reblogAccount) + } + } + + @Test + fun doNotOverwrite() { + val (status, author) = makeStatus() + timelineDao.insertInTransaction(status, author, null) + + val placeholder = createPlaceholder(status.serverId, status.timelineUserId) + + timelineDao.insertStatusIfNotThere(placeholder) + + val fromDb = timelineDao.getStatusesForAccount(status.timelineUserId, null, null, 10) + .blockingGet() + val result = fromDb.first() + + assertEquals(1, fromDb.size) + assertEquals(author, result.account) + assertEquals(status, result.status) + assertNull(result.reblogAccount) + + } + + @Test + fun cleanup() { + val now = System.currentTimeMillis() + val oldDate = now - TimelineRepository.CLEANUP_INTERVAL - 20_000 + val oldByThisAccount = makeStatus( + statusId = 30, + createdAt = oldDate + ) + val oldByAnotherAccount = makeStatus( + statusId = 10, + createdAt = oldDate, + authorServerId = "100" + ) + val oldForAnotherAccount = makeStatus( + accountId = 2, + statusId = 20, + authorServerId = "200", + createdAt = oldDate + ) + val recentByThisAccount = makeStatus( + statusId = 50, + createdAt = System.currentTimeMillis() + ) + val recentByAnotherAccount = makeStatus( + statusId = 60, + createdAt = System.currentTimeMillis(), + authorServerId = "200" + ) + + for ((status, author, reblogAuthor) in listOf(oldByThisAccount, oldByAnotherAccount, + oldForAnotherAccount, recentByThisAccount, recentByAnotherAccount)) { + timelineDao.insertInTransaction(status, author, reblogAuthor) + } + + timelineDao.cleanup(1, "20", now - TimelineRepository.CLEANUP_INTERVAL) + + assertEquals( + listOf(recentByAnotherAccount, recentByThisAccount, oldByThisAccount), + timelineDao.getStatusesForAccount(1, null, null, 100).blockingGet() + .map { it.toTriple() } + ) + + assertEquals( + listOf(oldForAnotherAccount), + timelineDao.getStatusesForAccount(2, null, null, 100).blockingGet() + .map { it.toTriple() } + ) + } + + private fun makeStatus( + accountId: Long = 1, + statusId: Long = 10, + reblog: Boolean = false, + createdAt: Long = statusId, + authorServerId: String = "20" + ): Triple { + val author = TimelineAccountEntity( + authorServerId, + accountId, + "birb.site", + "localUsername", + "username", + "displayName", + "blah", + "avatar", + "[\"tusky\": \"http://tusky.cool/emoji.jpg\"]" + ) + + val reblogAuthor = if (reblog) { + TimelineAccountEntity( + "R$authorServerId", + accountId, + "Rbirb.site", + "RlocalUsername", + "Rusername", + "RdisplayName", + "Rblah", + "Ravatar", + emojis = "[]" + ) + } else null + + + val even = accountId % 2 == 0L + val status = TimelineStatusEntity( + serverId = statusId.toString(), + url = "url$statusId", + timelineUserId = accountId, + authorServerId = authorServerId, + instance = "birb.site$statusId", + inReplyToId = "inReplyToId$statusId", + inReplyToAccountId = "inReplyToAccountId$statusId", + content = "Content!$statusId", + createdAt = createdAt, + emojis = "emojis$statusId", + reblogsCount = 1 * statusId.toInt(), + favouritesCount = 2 * statusId.toInt(), + reblogged = even, + favourited = !even, + sensitive = even, + spoilerText = "spoier$statusId", + visibility = Status.Visibility.PRIVATE, + attachments = "attachments$accountId", + mentions = "mentions$accountId", + application = "application$accountId", + reblogServerId = if (reblog) (statusId * 100).toString() else null, + reblogAccountId = reblogAuthor?.serverId + ) + return Triple(status, author, reblogAuthor) + } + + fun createPlaceholder(serverId: String, timelineUserId: Long): TimelineStatusEntity { + return TimelineStatusEntity( + serverId = serverId, + url = null, + timelineUserId = timelineUserId, + authorServerId = null, + instance = null, + inReplyToId = null, + inReplyToAccountId = null, + content = null, + createdAt = 0L, + emojis = null, + reblogsCount = 0, + favouritesCount = 0, + reblogged = false, + favourited = false, + sensitive = false, + spoilerText = null, + visibility = null, + attachments = null, + mentions = null, + application = null, + reblogServerId = null, + reblogAccountId = null + + ) + } + + private fun TimelineStatusWithAccount.toTriple() = Triple(status, account, reblogAccount) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java index 43af3589..e8e4bdff 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java @@ -36,6 +36,7 @@ import android.view.KeyEvent; import android.widget.ImageButton; import android.widget.ImageView; +import com.keylesspalace.tusky.appstore.CacheUpdater; import com.keylesspalace.tusky.appstore.EventHub; import com.keylesspalace.tusky.appstore.ProfileEditedEvent; import com.keylesspalace.tusky.db.AccountEntity; @@ -98,6 +99,8 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut public DispatchingAndroidInjector fragmentInjector; @Inject public EventHub eventHub; + @Inject + public CacheUpdater cacheUpdater; private FloatingActionButton composeButton; private AccountHeader headerResult; @@ -410,6 +413,7 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut private void changeAccount(long newSelectedId) { + cacheUpdater.stop(); accountManager.setActiveAccount(newSelectedId); Intent intent = new Intent(this, MainActivity.class); @@ -432,6 +436,7 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut .setPositiveButton(android.R.string.yes, (dialog, which) -> { NotificationHelper.deleteNotificationChannelsForAccount(accountManager.getActiveAccount(), MainActivity.this); + cacheUpdater.clearForUser(activeAccount.getId()); AccountEntity newAccount = accountManager.logActiveAccountOut(); diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java index 67ae9521..53ffc3dc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java @@ -66,7 +66,7 @@ public class TuskyApplication extends Application implements HasActivityInjector .allowMainThreadQueries() .addMigrations(AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7, AppDatabase.MIGRATION_7_8, - AppDatabase.MIGRATION_8_9, AppDatabase.MIGRATION_9_10) + AppDatabase.MIGRATION_8_9, AppDatabase.MIGRATION_9_10, AppDatabase.MIGRATION_10_11) .build(); accountManager = new AccountManager(appDatabase); serviceLocator = new ServiceLocator() { diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt new file mode 100644 index 00000000..fefe0836 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt @@ -0,0 +1,47 @@ +package com.keylesspalace.tusky.appstore + +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import io.reactivex.Single +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import javax.inject.Inject + +class CacheUpdater @Inject constructor( + eventHub: EventHub, + accountManager: AccountManager, + val appDatabase: AppDatabase +) { + + private val disposable: Disposable + + init { + val timelineDao = appDatabase.timelineDao() + disposable = eventHub.events.subscribe { event -> + val accountId = accountManager.activeAccount?.id ?: return@subscribe + when (event) { + is FavoriteEvent -> + timelineDao.setFavourited(accountId, event.statusId, event.favourite) + is ReblogEvent -> + timelineDao.setReblogged(accountId, event.statusId, event.reblog) + is UnfollowEvent -> + timelineDao.removeAllByUser(accountId, event.accountId) + is StatusDeletedEvent -> + timelineDao.delete(accountId, event.statusId) + } + } + } + + fun stop() { + this.disposable.dispose() + } + + fun clearForUser(accountId: Long) { + Single.fromCallable { + appDatabase.timelineDao().removeAllForAccount(accountId) + appDatabase.timelineDao().removeAllUsersForAccount(accountId) + } + .subscribeOn(Schedulers.io()) + .subscribe() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index 571c1625..93feb74f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -25,12 +25,15 @@ import androidx.annotation.NonNull; * DB version & declare DAO */ -@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class}, version = 10) +@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class,TimelineStatusEntity.class, + TimelineAccountEntity.class + }, version = 11) public abstract class AppDatabase extends RoomDatabase { public abstract TootDao tootDao(); public abstract AccountDao accountDao(); public abstract InstanceDao instanceDao(); + public abstract TimelineDao timelineDao(); public static final Migration MIGRATION_2_3 = new Migration(2, 3) { @Override @@ -116,4 +119,51 @@ public abstract class AppDatabase extends RoomDatabase { } }; + public static final Migration MIGRATION_10_11 = new Migration(10, 11) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineAccountEntity` (" + + "`serverId` TEXT NOT NULL, " + + "`timelineUserId` INTEGER NOT NULL, " + + "`instance` TEXT NOT NULL, " + + "`localUsername` TEXT NOT NULL, " + + "`username` TEXT NOT NULL, " + + "`displayName` TEXT NOT NULL, " + + "`url` TEXT NOT NULL, " + + "`avatar` TEXT NOT NULL, " + + "`emojis` TEXT NOT NULL," + + "PRIMARY KEY(`serverId`, `timelineUserId`))"); + + database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` (" + + "`serverId` TEXT NOT NULL, " + + "`url` TEXT, " + + "`timelineUserId` INTEGER NOT NULL, " + + "`authorServerId` TEXT," + + "`instance` TEXT, " + + "`inReplyToId` TEXT, " + + "`inReplyToAccountId` TEXT, " + + "`content` TEXT, " + + "`createdAt` INTEGER NOT NULL, " + + "`emojis` TEXT, " + + "`reblogsCount` INTEGER NOT NULL, " + + "`favouritesCount` INTEGER NOT NULL, " + + "`reblogged` INTEGER NOT NULL, " + + "`favourited` INTEGER NOT NULL, " + + "`sensitive` INTEGER NOT NULL, " + + "`spoilerText` TEXT, " + + "`visibility` INTEGER, " + + "`attachments` TEXT, " + + "`mentions` TEXT, " + + "`application` TEXT, " + + "`reblogServerId` TEXT, " + + "`reblogAccountId` TEXT," + + " PRIMARY KEY(`serverId`, `timelineUserId`)," + + " FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) " + + "ON UPDATE NO ACTION ON DELETE NO ACTION )"); + database.execSQL("CREATE INDEX IF NOT EXISTS" + + "`index_TimelineStatusEntity_authorServerId_timelineUserId` " + + "ON `TimelineStatusEntity` (`authorServerId`, `timelineUserId`)"); + } + }; + } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt new file mode 100644 index 00000000..d8191d2b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -0,0 +1,87 @@ +package com.keylesspalace.tusky.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy.IGNORE +import androidx.room.OnConflictStrategy.REPLACE +import androidx.room.Query +import androidx.room.Transaction +import io.reactivex.Single + +@Dao +abstract class TimelineDao { + + @Insert(onConflict = REPLACE) + abstract fun insertAccount(timelineAccountEntity: TimelineAccountEntity): Long + + + @Insert(onConflict = REPLACE) + abstract fun insertStatus(timelineAccountEntity: TimelineStatusEntity): Long + + + @Insert(onConflict = IGNORE) + abstract fun insertStatusIfNotThere(timelineAccountEntity: TimelineStatusEntity): Long + + @Query(""" +SELECT s.serverId, s.url, s.timelineUserId, +s.authorServerId, s.instance, s.inReplyToId, s.inReplyToAccountId, s.createdAt, +s.emojis, s.reblogsCount, s.favouritesCount, s.reblogged, s.favourited, s.sensitive, +s.spoilerText, s.visibility, s.mentions, s.application, s.reblogServerId,s.reblogAccountId, +s.content, s.attachments, +a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', a.instance as 'a_instance', +a.localUsername as 'a_localUsername', a.username as 'a_username', +a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', a.emojis as 'a_emojis', +rb.serverId as 'rb_serverId', rb.timelineUserId 'rb_timelineUserId', rb.instance as 'rb_instance', +rb.localUsername as 'rb_localUsername', rb.username as 'rb_username', +rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar', +rb.emojis as'rb_emojis' +FROM TimelineStatusEntity s +LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) +LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) +WHERE s.timelineUserId = :account +AND (CASE WHEN :maxId IS NOT NULL THEN s.serverId < :maxId ELSE 1 END) +AND (CASE WHEN :sinceId IS NOT NULL THEN s.serverId > :sinceId ELSE 1 END) +ORDER BY s.serverId DESC +LIMIT :limit""") + abstract fun getStatusesForAccount(account: Long, maxId: String?, sinceId: String?, limit: Int): Single> + + + @Transaction + open fun insertInTransaction(status: TimelineStatusEntity, account: TimelineAccountEntity, + reblogAccount: TimelineAccountEntity?) { + insertAccount(account) + reblogAccount?.let(this::insertAccount) + insertStatus(status) + } + + @Query("""DELETE FROM TimelineStatusEntity WHERE authorServerId = null +AND timelineUserId = :acccount AND serverId > :sinceId AND serverId < :maxId""") + abstract fun removeAllPlaceholdersBetween(acccount: Long, maxId: String, sinceId: String) + + @Query("""UPDATE TimelineStatusEntity SET favourited = :favourited +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId - :statusId)""") + abstract fun setFavourited(accountId: Long, statusId: String, favourited: Boolean) + + + @Query("""UPDATE TimelineStatusEntity SET reblogged = :reblogged +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId - :statusId)""") + abstract fun setReblogged(accountId: Long, statusId: String, reblogged: Boolean) + + @Query("""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND +(authorServerId = :userId OR reblogAccountId = :userId)""") + abstract fun removeAllByUser(accountId: Long, userId: String) + + @Query("DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId") + abstract fun removeAllForAccount(accountId: Long) + + @Query("DELETE FROM TimelineAccountEntity WHERE timelineUserId = :accountId") + abstract fun removeAllUsersForAccount(accountId: Long) + + @Query("""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId +AND serverId = :statusId""") + abstract fun delete(accountId: Long, statusId: String) + + @Query("""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId +AND authorServerId != :accountServerId AND createdAt < :olderThan""") + abstract fun cleanup(accountId: Long, accountServerId: String, olderThan: Long) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt new file mode 100644 index 00000000..a54dae94 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -0,0 +1,79 @@ +package com.keylesspalace.tusky.db + +import androidx.room.* +import com.keylesspalace.tusky.entity.Status + +/** + * We're trying to play smart here. Server sends us reblogs as two entities one embedded into + * another (reblogged status is a field inside of "reblog" status). But it's really inefficient from + * the DB perspective and doesn't matter much for the display/interaction purposes. + * What if when we store reblog we don't store almost empty "reblog status" but we store + * *reblogged* status and we embed "reblog status" into reblogged status. This reversed + * relationship takes much less space and is much faster to fetch (no N+1 type queries or JSON + * serialization). + * "Reblog status", if present, is marked by [reblogServerId], and [reblogAccountId] + * fields. + */ +@Entity( + primaryKeys = ["serverId", "timelineUserId"], + foreignKeys = ([ + ForeignKey( + entity = TimelineAccountEntity::class, + parentColumns = ["serverId", "timelineUserId"], + childColumns = ["authorServerId", "timelineUserId"] + ) + ]), + // Avoiding rescanning status table when accounts table changes. Recommended by Room(c). + indices = [Index("authorServerId", "timelineUserId")] +) +@TypeConverters(TootEntity.Converters::class) +data class TimelineStatusEntity( + val serverId: String, // id never flips: we need it for sorting so it's a real id + val url: String?, + // our local id for the logged in user in case there are multiple accounts per instance + val timelineUserId: Long, + val authorServerId: String?, + val instance: String?, + val inReplyToId: String?, + val inReplyToAccountId: String?, + val content: String?, + val createdAt: Long, + val emojis: String?, + val reblogsCount: Int, + val favouritesCount: Int, + val reblogged: Boolean, + val favourited: Boolean, + val sensitive: Boolean, + val spoilerText: String?, + val visibility: Status.Visibility?, + val attachments: String?, + val mentions: String?, + val application: String?, + val reblogServerId: String?, // if it has a reblogged status, it's id is stored here + val reblogAccountId: String? +) + +@Entity( + primaryKeys = ["serverId", "timelineUserId"] +) +data class TimelineAccountEntity( + val serverId: String, + val timelineUserId: Long, + val instance: String, + val localUsername: String, + val username: String, + val displayName: String, + val url: String, + val avatar: String, + val emojis: String +) + + +class TimelineStatusWithAccount { + @Embedded + lateinit var status: TimelineStatusEntity + @Embedded(prefix = "a_") + lateinit var account: TimelineAccountEntity + @Embedded(prefix = "rb_") + var reblogAccount: TimelineAccountEntity? = null +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TootEntity.java b/app/src/main/java/com/keylesspalace/tusky/db/TootEntity.java index 99e46be6..49ebef71 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TootEntity.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/TootEntity.java @@ -15,14 +15,14 @@ package com.keylesspalace.tusky.db; +import com.keylesspalace.tusky.entity.Status; + +import androidx.annotation.Nullable; import androidx.room.ColumnInfo; import androidx.room.Entity; import androidx.room.PrimaryKey; import androidx.room.TypeConverter; import androidx.room.TypeConverters; -import androidx.annotation.Nullable; - -import com.keylesspalace.tusky.entity.Status; /** * Toot model. @@ -120,8 +120,8 @@ public class TootEntity { } @TypeConverter - public int intToVisibility(Status.Visibility visibility) { - return visibility.getNum(); + public int intFromVisibility(Status.Visibility visibility) { + return visibility == null ? Status.Visibility.UNKNOWN.getNum() : visibility.getNum(); } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt index 66da6efe..996ad505 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt @@ -34,7 +34,8 @@ import javax.inject.Singleton ActivitiesModule::class, ServicesModule::class, BroadcastReceiverModule::class, - ViewModelModule::class + ViewModelModule::class, + RepositoryModule::class ]) interface AppComponent { @Component.Builder diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt index 473058ef..896aca15 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -86,7 +86,7 @@ class NetworkModule { @Singleton fun providesRetrofit(httpClient: OkHttpClient, converters: @JvmSuppressWildcards Set): Retrofit { - return Retrofit.Builder().baseUrl("https://"+MastodonApi.PLACEHOLDER_DOMAIN) + return Retrofit.Builder().baseUrl("https://" + MastodonApi.PLACEHOLDER_DOMAIN) .client(httpClient) .let { builder -> // Doing it this way in case builder will be immutable so we return the final diff --git a/app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt new file mode 100644 index 00000000..6db47709 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt @@ -0,0 +1,19 @@ +package com.keylesspalace.tusky.di + +import com.google.gson.Gson +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.repository.TimelineRepository +import com.keylesspalace.tusky.repository.TimelineRepositoryImpl +import dagger.Module +import dagger.Provides + +@Module +class RepositoryModule { + @Provides + fun providesTimelineRepository(db: AppDatabase, mastodonApi: MastodonApi, + accountManager: AccountManager, gson: Gson): TimelineRepository { + return TimelineRepositoryImpl(db.timelineDao(), mastodonApi, accountManager, gson) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index a21b47fc..64b207ab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -21,7 +21,7 @@ import java.util.* data class Status( var id: String, - var url: String, + var url: String?, // not present if it's reblog val account: Account, @SerializedName("in_reply_to_id") var inReplyToId: String?, @SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?, diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index 51c8d5f5..ffff2596 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -148,7 +148,7 @@ public class NotificationsFragment extends SFragment implements @Override public NotificationViewData apply(Either input) { if (input.isRight()) { - Notification notification = input.getAsRight(); + Notification notification = input.asRight(); return ViewDataUtils.notificationToViewData( notification, alwaysShowSensitiveMedia @@ -344,26 +344,22 @@ public class NotificationsFragment extends SFragment implements @Override public void onReply(int position) { - super.reply(notifications.get(position).getAsRight().getStatus()); + super.reply(notifications.get(position).asRight().getStatus()); } @Override public void onReblog(final boolean reblog, final int position) { - final Notification notification = notifications.get(position).getAsRight(); + final Notification notification = notifications.get(position).asRight(); final Status status = notification.getStatus(); - timelineCases.reblogWithCallback(status, reblog, new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull retrofit2.Response response) { - if (response.isSuccessful()) { - setReblogForStatus(position, status, reblog); - } - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - Log.d(getClass().getSimpleName(), "Failed to reblog status: " + status.getId(), t); - } - }); + Objects.requireNonNull(status, "Reblog on notification without status"); + timelineCases.reblog(status, reblog) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this))) + .subscribe( + (newStatus) -> setReblogForStatus(position, status, reblog), + (t) -> Log.d(getClass().getSimpleName(), + "Failed to reblog status: " + status.getId(), t) + ); } private void setReblogForStatus(int position, Status status, boolean reblog) { @@ -390,22 +386,17 @@ public class NotificationsFragment extends SFragment implements @Override public void onFavourite(final boolean favourite, final int position) { - final Notification notification = notifications.get(position).getAsRight(); + final Notification notification = notifications.get(position).asRight(); final Status status = notification.getStatus(); - timelineCases.favouriteWithCallback(status, favourite, new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull retrofit2.Response response) { - if (response.isSuccessful()) { - setFavovouriteForStatus(position, status, favourite); - } - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - Log.d(getClass().getSimpleName(), "Failed to favourite status: " + status.getId(), t); - } - }); + timelineCases.favourite(status, favourite) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this))) + .subscribe( + (newStatus) -> setFavovouriteForStatus(position, status, favourite), + (t) -> Log.d(getClass().getSimpleName(), + "Failed to favourite status: " + status.getId(), t) + ); } private void setFavovouriteForStatus(int position, Status status, boolean favourite) { @@ -431,26 +422,26 @@ public class NotificationsFragment extends SFragment implements @Override public void onMore(View view, int position) { - Notification notification = notifications.get(position).getAsRight(); + Notification notification = notifications.get(position).asRight(); super.more(notification.getStatus(), view, position); } @Override public void onViewMedia(int position, int attachmentIndex, View view) { - Notification notification = notifications.get(position).getAsRightOrNull(); + Notification notification = notifications.get(position).asRightOrNull(); if (notification == null || notification.getStatus() == null) return; super.viewMedia(attachmentIndex, notification.getStatus(), view); } @Override public void onViewThread(int position) { - Notification notification = notifications.get(position).getAsRight(); + Notification notification = notifications.get(position).asRight(); super.viewThread(notification.getStatus()); } @Override public void onOpenReblog(int position) { - Notification notification = notifications.get(position).getAsRight(); + Notification notification = notifications.get(position).asRight(); onViewAccount(notification.getAccount().getId()); } @@ -486,8 +477,8 @@ public class NotificationsFragment extends SFragment implements public void onLoadMore(int position) { //check bounds before accessing list, if (notifications.size() >= position && position > 0) { - Notification previous = notifications.get(position - 1).getAsRightOrNull(); - Notification next = notifications.get(position + 1).getAsRightOrNull(); + Notification previous = notifications.get(position - 1).asRightOrNull(); + Notification next = notifications.get(position + 1).asRightOrNull(); if (previous == null || next == null) { Log.e(TAG, "Failed to load more, invalid placeholder position: " + position); return; @@ -561,7 +552,7 @@ public class NotificationsFragment extends SFragment implements @Override public void onViewStatusForNotificationId(String notificationId) { for (Either either : notifications) { - Notification notification = either.getAsRightOrNull(); + Notification notification = either.asRightOrNull(); if (notification != null && notification.getId().equals(notificationId)) { super.viewThread(notification.getStatus()); return; @@ -598,7 +589,7 @@ public class NotificationsFragment extends SFragment implements Iterator> iterator = notifications.iterator(); while (iterator.hasNext()) { Either notification = iterator.next(); - Notification maybeNotification = notification.getAsRightOrNull(); + Notification maybeNotification = notification.asRightOrNull(); if (maybeNotification != null && maybeNotification.getAccount().getId().equals(accountId)) { iterator.remove(); } @@ -607,7 +598,7 @@ public class NotificationsFragment extends SFragment implements } private void onLoadMore() { - if(bottomId == null) { + if (bottomId == null) { // already loaded everything return; } @@ -618,7 +609,7 @@ public class NotificationsFragment extends SFragment implements if (notifications.size() > 0) { Either last = notifications.get(notifications.size() - 1); if (last.isRight()) { - notifications.add(Either.left(Placeholder.getInstance())); + notifications.add(new Either.Left(Placeholder.getInstance())); NotificationViewData viewData = new NotificationViewData.Placeholder(true); notifications.setPairedItem(notifications.size() - 1, viewData); recyclerView.post(() -> adapter.addItems(Collections.singletonList(viewData))); @@ -643,10 +634,10 @@ public class NotificationsFragment extends SFragment implements if (fetchEnd == FetchEnd.BOTTOM && bottomLoading) { return; } - if(fetchEnd == FetchEnd.TOP) { + if (fetchEnd == FetchEnd.TOP) { topLoading = true; } - if(fetchEnd == FetchEnd.BOTTOM) { + if (fetchEnd == FetchEnd.BOTTOM) { bottomLoading = true; } @@ -722,10 +713,10 @@ public class NotificationsFragment extends SFragment implements saveNewestNotificationId(notifications); - if(fetchEnd == FetchEnd.TOP) { + if (fetchEnd == FetchEnd.TOP) { topLoading = false; } - if(fetchEnd == FetchEnd.BOTTOM) { + if (fetchEnd == FetchEnd.BOTTOM) { bottomLoading = false; } @@ -753,7 +744,7 @@ public class NotificationsFragment extends SFragment implements private void saveNewestNotificationId(List notifications) { AccountEntity account = accountManager.getActiveAccount(); - if(account != null) { + if (account != null) { BigInteger lastNoti = new BigInteger(account.getLastNotificationId()); for (Notification noti : notifications) { @@ -764,7 +755,7 @@ public class NotificationsFragment extends SFragment implements } String lastNotificationId = lastNoti.toString(); - if(!account.getLastNotificationId().equals(lastNotificationId)) { + if (!account.getLastNotificationId().equals(lastNotificationId)) { Log.d(TAG, "saving newest noti id: " + lastNotificationId); account.setLastNotificationId(lastNotificationId); accountManager.saveAccount(account); @@ -796,7 +787,7 @@ public class NotificationsFragment extends SFragment implements int newIndex = liftedNew.indexOf(notifications.get(0)); if (newIndex == -1) { if (index == -1 && liftedNew.size() >= LOAD_AT_ONCE) { - liftedNew.add(Either.left(Placeholder.getInstance())); + liftedNew.add(new Either.Left(Placeholder.getInstance())); } notifications.addAll(0, liftedNew); } else { @@ -838,7 +829,7 @@ public class NotificationsFragment extends SFragment implements // If we fetched at least as much it means that there are more posts to load and we should // insert new placeholder if (newNotifications.size() >= LOAD_AT_ONCE) { - liftedNew.add(Either.left(Placeholder.getInstance())); + liftedNew.add(new Either.Left(Placeholder.getInstance())); } notifications.addAll(pos, liftedNew); @@ -846,7 +837,7 @@ public class NotificationsFragment extends SFragment implements } private final Function> notificationLifter = - Either::right; + Either.Right::new; private List> liftNotificationList(List list) { return CollectionUtil.map(list, notificationLifter); @@ -861,7 +852,7 @@ public class NotificationsFragment extends SFragment implements @Nullable private Pair findReplyPosition(@NonNull String statusId) { for (int i = 0; i < notifications.size(); i++) { - Notification notification = notifications.get(i).getAsRightOrNull(); + Notification notification = notifications.get(i).asRightOrNull(); if (notification != null && notification.getStatus() != null && notification.getType() == Notification.Type.MENTION diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt index 0a15503c..33e0a2ad 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt @@ -24,17 +24,20 @@ import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.lifecycle.Lifecycle import com.keylesspalace.tusky.AccountActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewTagActivity import com.keylesspalace.tusky.adapter.SearchResultsAdapter import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.SearchResults -import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.util.ViewDataUtils import com.keylesspalace.tusky.viewdata.StatusViewData +import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from +import com.uber.autodispose.autoDisposable +import io.reactivex.android.schedulers.AndroidSchedulers import kotlinx.android.synthetic.main.fragment_search.* import retrofit2.Call import retrofit2.Callback @@ -111,14 +114,14 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { } private fun displayNoResults() { - if(isAdded) { + if (isAdded) { searchProgressBar.visibility = View.GONE searchNoResultsText.visibility = View.VISIBLE } } private fun hideFeedback() { - if(isAdded) { + if (isAdded) { searchProgressBar.visibility = View.GONE searchNoResultsText.visibility = View.GONE } @@ -134,7 +137,7 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { override fun onReply(position: Int) { val status = searchAdapter.getStatusAtPosition(position) - if(status != null) { + if (status != null) { super.reply(status) } } @@ -142,51 +145,44 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { override fun onReblog(reblog: Boolean, position: Int) { val status = searchAdapter.getStatusAtPosition(position) if (status != null) { - timelineCases.reblogWithCallback(status, reblog, object: Callback { - override fun onResponse(call: Call?, response: Response?) { - status.reblogged = true - searchAdapter.updateStatusAtPosition( - ViewDataUtils.statusToViewData( - status, - alwaysShowSensitiveMedia - ), - position - ) - } - - override fun onFailure(call: Call?, t: Throwable?) { - Log.d(TAG, "Failed to reblog status " + status.id, t) - } - }) + timelineCases.reblog(status, reblog) + .observeOn(AndroidSchedulers.mainThread()) + .autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)) + .subscribe({ + status.reblogged = reblog + searchAdapter.updateStatusAtPosition( + ViewDataUtils.statusToViewData( + status, + alwaysShowSensitiveMedia + ), + position + ) + }, { t -> Log.d(TAG, "Failed to reblog status " + status.id, t) }) } } override fun onFavourite(favourite: Boolean, position: Int) { val status = searchAdapter.getStatusAtPosition(position) - if(status != null) { - timelineCases.favouriteWithCallback(status, favourite, object: Callback { - override fun onResponse(call: Call?, response: Response?) { - status.favourited = true - searchAdapter.updateStatusAtPosition( - ViewDataUtils.statusToViewData( - status, - alwaysShowSensitiveMedia - ), - position - ) - } - - override fun onFailure(call: Call?, t: Throwable?) { - Log.d(TAG, "Failed to favourite status " + status.id, t) - } - - }) + if (status != null) { + timelineCases.favourite(status, favourite) + .observeOn(AndroidSchedulers.mainThread()) + .autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)) + .subscribe({ + status.favourited = favourite + searchAdapter.updateStatusAtPosition( + ViewDataUtils.statusToViewData( + status, + alwaysShowSensitiveMedia + ), + position + ) + }, { t -> Log.d(TAG, "Failed to favourite status " + status.id, t) }) } } override fun onMore(view: View?, position: Int) { val status = searchAdapter.getStatusAtPosition(position) - if(status != null) { + if (status != null) { more(status, view, position) } } @@ -198,7 +194,7 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { override fun onViewThread(position: Int) { val status = searchAdapter.getStatusAtPosition(position) - if(status != null) { + if (status != null) { viewThread(status) } } @@ -209,7 +205,7 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { override fun onExpandedChange(expanded: Boolean, position: Int) { val status = searchAdapter.getConcreteStatusAtPosition(position) - if(status != null) { + if (status != null) { val newStatus = StatusViewData.Builder(status) .setIsExpanded(expanded).createStatusViewData() searchAdapter.updateStatusAtPosition(newStatus, position) @@ -218,7 +214,7 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { override fun onContentHiddenChange(isShowing: Boolean, position: Int) { val status = searchAdapter.getConcreteStatusAtPosition(position) - if(status != null) { + if (status != null) { val newStatus = StatusViewData.Builder(status) .setIsShowingSensitiveContent(isShowing).createStatusViewData() searchAdapter.updateStatusAtPosition(newStatus, position) @@ -232,7 +228,7 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { // TODO: No out-of-bounds check in getConcreteStatusAtPosition val status = searchAdapter.getConcreteStatusAtPosition(position) - if(status == null) { + if (status == null) { Log.e(TAG, String.format("Tried to access status but got null at position: %d", position)) return } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java index 8716fb60..7cbdd3f0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -15,28 +15,11 @@ package com.keylesspalace.tusky.fragment; -import androidx.arch.core.util.Function; -import androidx.lifecycle.Lifecycle; import android.content.Context; import android.content.SharedPreferences; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.preference.PreferenceManager; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.google.android.material.tabs.TabLayout; -import androidx.core.util.Pair; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; -import androidx.appcompat.content.res.AppCompatResources; -import androidx.recyclerview.widget.AsyncDifferConfig; -import androidx.recyclerview.widget.AsyncListDiffer; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.ListUpdateCallback; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.SimpleItemAnimator; import android.util.Log; import android.view.LayoutInflater; import android.view.View; @@ -44,6 +27,8 @@ import android.view.ViewGroup; import android.widget.ProgressBar; import android.widget.TextView; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.google.android.material.tabs.TabLayout; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.adapter.TimelineAdapter; import com.keylesspalace.tusky.appstore.BlockEvent; @@ -62,9 +47,11 @@ import com.keylesspalace.tusky.interfaces.ActionButtonActivity; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.TimelineCases; +import com.keylesspalace.tusky.repository.Placeholder; +import com.keylesspalace.tusky.repository.TimelineRepository; +import com.keylesspalace.tusky.repository.TimelineRequestMode; import com.keylesspalace.tusky.util.CollectionUtil; import com.keylesspalace.tusky.util.Either; -import com.keylesspalace.tusky.util.HttpHeaderLink; import com.keylesspalace.tusky.util.ListUtils; import com.keylesspalace.tusky.util.PairedList; import com.keylesspalace.tusky.util.ThemeUtils; @@ -72,16 +59,34 @@ import com.keylesspalace.tusky.util.ViewDataUtils; import com.keylesspalace.tusky.view.EndlessOnScrollListener; import com.keylesspalace.tusky.viewdata.StatusViewData; +import java.math.BigInteger; import java.util.Iterator; import java.util.List; +import java.util.ListIterator; import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.inject.Inject; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.arch.core.util.Function; +import androidx.core.util.Pair; +import androidx.lifecycle.Lifecycle; +import androidx.recyclerview.widget.AsyncDifferConfig; +import androidx.recyclerview.widget.AsyncListDiffer; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.DividerItemDecoration; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.ListUpdateCallback; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.SimpleItemAnimator; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import at.connyduck.sparkbutton.helpers.Utils; import io.reactivex.android.schedulers.AndroidSchedulers; +import kotlin.collections.CollectionsKt; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; @@ -120,6 +125,9 @@ public class TimelineFragment extends SFragment implements public TimelineCases timelineCases; @Inject public EventHub eventHub; + @Inject + public TimelineRepository timelineRepo; + @Inject public AccountManager accountManager; @@ -143,14 +151,9 @@ public class TimelineFragment extends SFragment implements private boolean hideFab; private boolean bottomLoading; - @Nullable - private String bottomId = null; - @Nullable - private String topId = null; - private long maxPlaceholderId = -1; private boolean didLoadEverythingBottom; - private boolean alwaysShowSensitiveMedia; + private boolean initialUpdateFailed = false; @Override protected TimelineCases timelineCases() { @@ -161,15 +164,15 @@ public class TimelineFragment extends SFragment implements new PairedList<>(new Function, StatusViewData>() { @Override public StatusViewData apply(Either input) { - Status status = input.getAsRightOrNull(); + Status status = input.asRightOrNull(); if (status != null) { return ViewDataUtils.statusToViewData( status, alwaysShowSensitiveMedia ); } else { - Placeholder placeholder = input.getAsLeft(); - return new StatusViewData.Placeholder(placeholder.id, false); + Placeholder placeholder = input.asLeft(); + return new StatusViewData.Placeholder(placeholder.getId(), false); } } }); @@ -191,18 +194,6 @@ public class TimelineFragment extends SFragment implements return fragment; } - private static final class Placeholder { - final long id; - - public static Placeholder getInstance(long id) { - return new Placeholder(id); - } - - private Placeholder(long id) { - this.id = id; - } - } - @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -238,7 +229,7 @@ public class TimelineFragment extends SFragment implements if (statuses.isEmpty()) { progressBar.setVisibility(View.VISIBLE); bottomLoading = true; - sendFetchTimelineRequest(null, null, FetchEnd.BOTTOM, -1); + this.sendInitialRequest(); } else { progressBar.setVisibility(View.GONE); } @@ -246,6 +237,80 @@ public class TimelineFragment extends SFragment implements return rootView; } + private void sendInitialRequest() { + if (this.kind == Kind.HOME) { + this.tryCache(); + } else { + sendFetchTimelineRequest(null, null, FetchEnd.BOTTOM, -1); + } + } + + private void tryCache() { + // Request timeline from disk to make it quick, then replace it with timeline from + // the server to update it + this.timelineRepo.getStatuses(null, null, LOAD_AT_ONCE, + TimelineRequestMode.DISK) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe(statuses -> { + filterStatuses(statuses); + + if (statuses.size() > 1) { + this.clearPlaceholdersForResponse(statuses); + this.statuses.clear(); + this.statuses.addAll(statuses); + this.updateAdapter(); + this.progressBar.setVisibility(View.GONE); + // Request statuses including current top to refresh all of them + } + + this.updateCurrent(); + }); + } + + private void updateCurrent() { + String topId; + if (this.statuses.isEmpty()) { + topId = null; + } else { + topId = CollectionsKt.first(statuses, Either::isRight).asRight().getId(); + } + this.timelineRepo.getStatuses(topId, null, LOAD_AT_ONCE, + TimelineRequestMode.NETWORK) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe( + (statuses) -> { + this.initialUpdateFailed = false; + // When cached timeline is too old, we would replace it with nothing + if (!statuses.isEmpty()) { + filterStatuses(statuses); + + // Working around a bug when Mastodon API doesn't return the first + // status because of string "id < maxId". Hacking with ID doesn't + // help. + if (!this.statuses.isEmpty()) { + Either firstOld = this.statuses.get(0); + this.statuses.clear(); + this.statuses.add(firstOld); + } else { + this.statuses.clear(); + } + this.statuses.addAll(statuses); + this.updateAdapter(); + } + this.bottomLoading = false; + // Get more statuses so that users know that something is there + this.loadAbove(); + }, + (e) -> { + this.initialUpdateFailed = true; + // Indicate that we are not loading anymore + this.progressBar.setVisibility(View.GONE); + this.swipeRefreshLayout.setRefreshing(false); + }); + } + private void setupTimelinePreferences() { SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); @@ -302,7 +367,7 @@ public class TimelineFragment extends SFragment implements for (int i = 0; i < statuses.size(); i++) { Either either = statuses.get(i); if (either.isRight() - && id.equals(either.getAsRight().getId())) { + && id.equals(either.asRight().getId())) { statuses.remove(either); updateAdapter(); break; @@ -443,31 +508,38 @@ public class TimelineFragment extends SFragment implements @Override public void onRefresh() { - sendFetchTimelineRequest(null, topId, FetchEnd.TOP, -1); + if (this.initialUpdateFailed) { + updateCurrent(); + } else { + this.loadAbove(); + } + } + + private void loadAbove() { + Either firstOrNull = + CollectionsKt.firstOrNull(this.statuses, Either::isRight); + if (firstOrNull != null) { + this.sendFetchTimelineRequest(null, firstOrNull.asRight().getId(), FetchEnd.TOP, -1); + } else { + this.sendFetchTimelineRequest(null, null, FetchEnd.BOTTOM, -1); + } } @Override public void onReply(int position) { - super.reply(statuses.get(position).getAsRight()); + super.reply(statuses.get(position).asRight()); } @Override public void onReblog(final boolean reblog, final int position) { - final Status status = statuses.get(position).getAsRight(); - timelineCases.reblogWithCallback(status, reblog, new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - - if (response.isSuccessful()) { - setRebloggedForStatus(position, status, reblog); - } - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - Log.d(TAG, "Failed to reblog status " + status.getId(), t); - } - }); + final Status status = statuses.get(position).asRight(); + timelineCases.reblog(status, reblog) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe( + (newStatus) -> setRebloggedForStatus(position, status, reblog), + (err) -> Log.d(TAG, "Failed to reblog status " + status.getId(), err) + ); } private void setRebloggedForStatus(int position, Status status, boolean reblog) { @@ -491,22 +563,15 @@ public class TimelineFragment extends SFragment implements @Override public void onFavourite(final boolean favourite, final int position) { - final Status status = statuses.get(position).getAsRight(); + final Status status = statuses.get(position).asRight(); - timelineCases.favouriteWithCallback(status, favourite, new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - - if (response.isSuccessful()) { - setFavouriteForStatus(position, status, favourite); - } - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - Log.d(TAG, "Failed to favourite status " + status.getId(), t); - } - }); + timelineCases.favourite(status, favourite) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe( + (newStatus) -> setFavouriteForStatus(position, newStatus, favourite), + (err) -> Log.d(TAG, "Failed to favourite status " + status.getId(), err) + ); } private void setFavouriteForStatus(int position, Status status, boolean favourite) { @@ -530,12 +595,12 @@ public class TimelineFragment extends SFragment implements @Override public void onMore(View view, final int position) { - super.more(statuses.get(position).getAsRight(), view, position); + super.more(statuses.get(position).asRight(), view, position); } @Override public void onOpenReblog(int position) { - super.openReblog(statuses.get(position).getAsRight()); + super.openReblog(statuses.get(position).asRight()); } @Override @@ -560,16 +625,16 @@ public class TimelineFragment extends SFragment implements public void onLoadMore(int position) { //check bounds before accessing list, if (statuses.size() >= position && position > 0) { - Status fromStatus = statuses.get(position - 1).getAsRightOrNull(); - Status toStatus = statuses.get(position + 1).getAsRightOrNull(); + Status fromStatus = statuses.get(position - 1).asRightOrNull(); + Status toStatus = statuses.get(position + 1).asRightOrNull(); if (fromStatus == null || toStatus == null) { Log.e(TAG, "Failed to load more at " + position + ", wrong placeholder position"); return; } sendFetchTimelineRequest(fromStatus.getId(), toStatus.getId(), FetchEnd.MIDDLE, position); - Placeholder placeholder = statuses.get(position).getAsLeft(); - StatusViewData newViewData = new StatusViewData.Placeholder(placeholder.id, true); + Placeholder placeholder = statuses.get(position).asLeft(); + StatusViewData newViewData = new StatusViewData.Placeholder(placeholder.getId(), true); statuses.setPairedItem(position, newViewData); updateAdapter(); } else { @@ -606,14 +671,14 @@ public class TimelineFragment extends SFragment implements @Override public void onViewMedia(int position, int attachmentIndex, View view) { - Status status = statuses.get(position).getAsRightOrNull(); + Status status = statuses.get(position).asRightOrNull(); if (status == null) return; super.viewMedia(attachmentIndex, status, view); } @Override public void onViewThread(int position) { - super.viewThread(statuses.get(position).getAsRight()); + super.viewThread(statuses.get(position).asRight()); } @Override @@ -703,7 +768,7 @@ public class TimelineFragment extends SFragment implements // using iterator to safely remove items while iterating Iterator> iterator = statuses.iterator(); while (iterator.hasNext()) { - Status status = iterator.next().getAsRightOrNull(); + Status status = iterator.next().asRightOrNull(); if (status != null && status.getAccount().getId().equals(accountId)) { iterator.remove(); } @@ -720,16 +785,29 @@ public class TimelineFragment extends SFragment implements Either last = statuses.get(statuses.size() - 1); Placeholder placeholder; if (last.isRight()) { - placeholder = newPlaceholder(); - statuses.add(Either.left(placeholder)); + final String placeholderId = new BigInteger(last.asRight().getId()) + .subtract(BigInteger.ONE) + .toString(); + placeholder = new Placeholder(placeholderId); + statuses.add(new Either.Left<>(placeholder)); } else { - placeholder = last.getAsLeft(); + placeholder = last.asLeft(); } statuses.setPairedItem(statuses.size() - 1, - new StatusViewData.Placeholder(placeholder.id, true)); + new StatusViewData.Placeholder(placeholder.getId(), true)); updateAdapter(); + String bottomId = null; + final ListIterator> iterator = + this.statuses.listIterator(this.statuses.size()); + while (iterator.hasPrevious()) { + Either previous = iterator.previous(); + if (previous.isRight()) { + bottomId = previous.asRight().getId(); + break; + } + } sendFetchTimelineRequest(bottomId, null, FetchEnd.BOTTOM, -1); } @@ -782,44 +860,54 @@ public class TimelineFragment extends SFragment implements private void sendFetchTimelineRequest(@Nullable String fromId, @Nullable String uptoId, final FetchEnd fetchEnd, final int pos) { - Callback> callback = new Callback>() { - @Override - public void onResponse(@NonNull Call> call, @NonNull Response> response) { - if (response.isSuccessful()) { - String linkHeader = response.headers().get("Link"); - onFetchTimelineSuccess(response.body(), linkHeader, fetchEnd, pos); - } else { - onFetchTimelineFailure(new Exception(response.message()), fetchEnd, pos); + if (kind == Kind.HOME) { + TimelineRequestMode mode; + // allow getting old statuses/fallbacks for network only for for bottom loading + if (fetchEnd == FetchEnd.BOTTOM) { + mode = TimelineRequestMode.ANY; + } else { + mode = TimelineRequestMode.NETWORK; + } + timelineRepo.getStatuses(fromId, uptoId, LOAD_AT_ONCE, mode) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe( + (result) -> onFetchTimelineSuccess(result, fetchEnd, pos), + (err) -> onFetchTimelineFailure(new Exception(err), fetchEnd, pos) + ); + } else { + Callback> callback = new Callback>() { + @Override + public void onResponse(@NonNull Call> call, @NonNull Response> response) { + if (response.isSuccessful()) { + onFetchTimelineSuccess(liftStatusList(response.body()), fetchEnd, pos); + } else { + onFetchTimelineFailure(new Exception(response.message()), fetchEnd, pos); + } } - } - @Override - public void onFailure(@NonNull Call> call, @NonNull Throwable t) { - onFetchTimelineFailure((Exception) t, fetchEnd, pos); - } - }; + @Override + public void onFailure(@NonNull Call> call, @NonNull Throwable t) { + onFetchTimelineFailure((Exception) t, fetchEnd, pos); + } + }; - Call> listCall = getFetchCallByTimelineType(kind, hashtagOrId, fromId, uptoId); - callList.add(listCall); - listCall.enqueue(callback); + Call> listCall = getFetchCallByTimelineType(kind, hashtagOrId, fromId, uptoId); + callList.add(listCall); + listCall.enqueue(callback); + } } - private void onFetchTimelineSuccess(List statuses, String linkHeader, + private void onFetchTimelineSuccess(List> statuses, FetchEnd fetchEnd, int pos) { // We filled the hole (or reached the end) if the server returned less statuses than we // we asked for. boolean fullFetch = statuses.size() >= LOAD_AT_ONCE; filterStatuses(statuses); - List links = HttpHeaderLink.parse(linkHeader); switch (fetchEnd) { case TOP: { - HttpHeaderLink previous = HttpHeaderLink.findByRelationType(links, "prev"); - String uptoId = null; - if (previous != null) { - uptoId = previous.uri.getQueryParameter("since_id"); - } - updateStatuses(statuses, null, uptoId, fullFetch); + updateStatuses(statuses, fullFetch); break; } case MIDDLE: { @@ -827,29 +915,21 @@ public class TimelineFragment extends SFragment implements break; } case BOTTOM: { - HttpHeaderLink next = HttpHeaderLink.findByRelationType(links, "next"); - String fromId = null; - if (next != null) { - fromId = next.uri.getQueryParameter("max_id"); - } if (!this.statuses.isEmpty() && !this.statuses.get(this.statuses.size() - 1).isRight()) { this.statuses.remove(this.statuses.size() - 1); updateAdapter(); } + + if (!statuses.isEmpty() && !statuses.get(statuses.size() - 1).isRight()) { + // Removing placeholder if it's the last one from the cache + statuses.remove(statuses.size() - 1); + } int oldSize = this.statuses.size(); if (this.statuses.size() > 1) { - addItems(statuses, fromId); + addItems(statuses); } else { - /* If this is the first fetch, also save the id from the "previous" link and - * treat this operation as a refresh so the scroll position doesn't get pushed - * down to the end. */ - HttpHeaderLink previous = HttpHeaderLink.findByRelationType(links, "prev"); - String uptoId = null; - if (previous != null) { - uptoId = previous.uri.getQueryParameter("since_id"); - } - updateStatuses(statuses, fromId, uptoId, fullFetch); + updateStatuses(statuses, fullFetch); } if (this.statuses.size() == oldSize) { // This may be a brittle check but seems like it works @@ -859,7 +939,7 @@ public class TimelineFragment extends SFragment implements break; } } - fulfillAnyQueuedFetches(fetchEnd); + updateBottomLoadingState(fetchEnd); progressBar.setVisibility(View.GONE); swipeRefreshLayout.setRefreshing(false); if (this.statuses.size() == 0) { @@ -874,23 +954,25 @@ public class TimelineFragment extends SFragment implements swipeRefreshLayout.setRefreshing(false); if (fetchEnd == FetchEnd.MIDDLE && !statuses.get(position).isRight()) { - Placeholder placeholder = statuses.get(position).getAsLeftOrNull(); + Placeholder placeholder = statuses.get(position).asLeftOrNull(); StatusViewData newViewData; if (placeholder == null) { - placeholder = newPlaceholder(); + Status above = statuses.get(position - 1).asRight(); + String newId = this.idPlus(above.getId(), -1); + placeholder = new Placeholder(newId); } - newViewData = new StatusViewData.Placeholder(placeholder.id, false); + newViewData = new StatusViewData.Placeholder(placeholder.getId(), false); statuses.setPairedItem(position, newViewData); updateAdapter(); } Log.e(TAG, "Fetch Failure: " + exception.getMessage()); - fulfillAnyQueuedFetches(fetchEnd); + updateBottomLoadingState(fetchEnd); progressBar.setVisibility(View.GONE); } } - private void fulfillAnyQueuedFetches(FetchEnd fetchEnd) { + private void updateBottomLoadingState(FetchEnd fetchEnd) { switch (fetchEnd) { case BOTTOM: { bottomLoading = false; @@ -899,80 +981,90 @@ public class TimelineFragment extends SFragment implements } } - private void filterStatuses(List statuses) { - Iterator it = statuses.iterator(); + private void filterStatuses(List> statuses) { + Iterator> it = statuses.iterator(); while (it.hasNext()) { - Status status = it.next(); - if ((status.getInReplyToId() != null && filterRemoveReplies) + Status status = it.next().asRightOrNull(); + if (status != null + && ((status.getInReplyToId() != null && filterRemoveReplies) || (status.getReblog() != null && filterRemoveReblogs) || (filterRemoveRegex && (filterRemoveRegexMatcher.reset(status.getContent()).find() - || (!status.getSpoilerText().isEmpty() && filterRemoveRegexMatcher.reset(status.getSpoilerText()).find())))) { + || (!status.getSpoilerText().isEmpty() && filterRemoveRegexMatcher.reset(status.getSpoilerText()).find()))))) { it.remove(); } } } - private void updateStatuses(List newStatuses, @Nullable String fromId, - @Nullable String toId, boolean fullFetch) { + private void updateStatuses(List> newStatuses, boolean fullFetch) { if (ListUtils.isEmpty(newStatuses)) { return; } - if (fromId != null) { - bottomId = fromId; - } - if (toId != null) { - topId = toId; - } - - List> liftedNew = liftStatusList(newStatuses); if (statuses.isEmpty()) { - statuses.addAll(liftedNew); + statuses.addAll(newStatuses); } else { - Either lastOfNew = liftedNew.get(newStatuses.size() - 1); + Either lastOfNew = newStatuses.get(newStatuses.size() - 1); int index = statuses.indexOf(lastOfNew); for (int i = 0; i < index; i++) { statuses.remove(0); } - int newIndex = liftedNew.indexOf(statuses.get(0)); + int newIndex = newStatuses.indexOf(statuses.get(0)); if (newIndex == -1) { if (index == -1 && fullFetch) { - liftedNew.add(Either.left(newPlaceholder())); + String placeholderId = idPlus(CollectionsKt.last(newStatuses, Either::isRight) + .asRight().getId(), 1); + newStatuses.add(new Either.Left<>(new Placeholder(placeholderId))); } - statuses.addAll(0, liftedNew); + statuses.addAll(0, newStatuses); } else { - statuses.addAll(0, liftedNew.subList(0, newIndex)); + statuses.addAll(0, newStatuses.subList(0, newIndex)); } } + // Remove all consecutive placeholders + removeConsecutivePlaceholders(); updateAdapter(); } - private void addItems(List newStatuses, @Nullable String fromId) { + private void removeConsecutivePlaceholders() { + for (int i = 0; i < statuses.size() - 1; i++) { + if (!statuses.get(i).isRight() && !statuses.get(i + 1).isRight()) { + statuses.remove(i); + } + } + } + + private void addItems(List> newStatuses) { if (ListUtils.isEmpty(newStatuses)) { return; } - Status last = null; + Either last = null; for (int i = statuses.size() - 1; i >= 0; i--) { if (statuses.get(i).isRight()) { - last = statuses.get(i).getAsRight(); + last = statuses.get(i); break; } } // I was about to replace findStatus with indexOf but it is incorrect to compare value // types by ID anyway and we should change equals() for Status, I think, so this makes sense - if (last != null && !findStatus(newStatuses, last.getId())) { - statuses.addAll(liftStatusList(newStatuses)); - if (fromId != null) { - bottomId = fromId; - } + if (last != null && !newStatuses.contains(last)) { + statuses.addAll(newStatuses); + removeConsecutivePlaceholders(); updateAdapter(); } } - private void replacePlaceholderWithStatuses(List newStatuses, boolean fullFetch, int pos) { - Status status = statuses.get(pos).getAsRightOrNull(); - if (status == null) { + /** + * For certain requests we don't want to see placeholders, they will be removed some other way + */ + private void clearPlaceholdersForResponse(List> statuses) { + CollectionsKt.removeAll(statuses, s -> !s.isRight()); + } + + private void replacePlaceholderWithStatuses(List> newStatuses, + boolean fullFetch, int pos) { + Either placeholder = statuses.get(pos); + if (!placeholder.isRight()) { statuses.remove(pos); } @@ -981,29 +1073,20 @@ public class TimelineFragment extends SFragment implements return; } - List> liftedNew = liftStatusList(newStatuses); - if (fullFetch) { - liftedNew.add(Either.left(newPlaceholder())); + newStatuses.add(placeholder); } - statuses.addAll(pos, liftedNew); + statuses.addAll(pos, newStatuses); + removeConsecutivePlaceholders(); + updateAdapter(); } - private static boolean findStatus(List statuses, String id) { - for (Status status : statuses) { - if (status.getId().equals(id)) { - return true; - } - } - return false; - } - private int findStatusOrReblogPositionById(@NonNull String statusId) { for (int i = 0; i < statuses.size(); i++) { - Status status = statuses.get(i).getAsRightOrNull(); + Status status = statuses.get(i).asRightOrNull(); if (status != null && (statusId.equals(status.getId()) || (status.getReblog() != null @@ -1015,7 +1098,7 @@ public class TimelineFragment extends SFragment implements } private final Function> statusLifter = - Either::right; + Either.Right::new; private @Nullable Pair @@ -1028,7 +1111,7 @@ public class TimelineFragment extends SFragment implements if ((someOldViewData instanceof StatusViewData.Placeholder) || !((StatusViewData.Concrete) someOldViewData).getId().equals(status.getId())) { // try to find the status we need to update - int foundPos = statuses.indexOf(Either.right(status)); + int foundPos = statuses.indexOf(new Either.Right<>(status)); if (foundPos < 0) return null; // okay, it's hopeless, give up statusToUpdate = ((StatusViewData.Concrete) statuses.getPairedItem(foundPos)); @@ -1043,14 +1126,14 @@ public class TimelineFragment extends SFragment implements private void handleReblogEvent(@NonNull ReblogEvent reblogEvent) { int pos = findStatusOrReblogPositionById(reblogEvent.getStatusId()); if (pos < 0) return; - Status status = statuses.get(pos).getAsRight(); + Status status = statuses.get(pos).asRight(); setRebloggedForStatus(pos, status, reblogEvent.getReblog()); } private void handleFavEvent(@NonNull FavoriteEvent favEvent) { int pos = findStatusOrReblogPositionById(favEvent.getStatusId()); if (pos < 0) return; - Status status = statuses.get(pos).getAsRight(); + Status status = statuses.get(pos).asRight(); setFavouriteForStatus(pos, status, favEvent.getFavourite()); } @@ -1079,12 +1162,6 @@ public class TimelineFragment extends SFragment implements return CollectionUtil.map(list, statusLifter); } - private Placeholder newPlaceholder() { - Placeholder placeholder = Placeholder.getInstance(maxPlaceholderId); - maxPlaceholderId--; - return placeholder; - } - private void updateAdapter() { differ.submitList(statuses.getPairedCopy()); } @@ -1144,8 +1221,12 @@ public class TimelineFragment extends SFragment implements } @Override - public boolean areContentsTheSame(StatusViewData oldItem, StatusViewData newItem) { + public boolean areContentsTheSame(StatusViewData oldItem, @NonNull StatusViewData newItem) { return oldItem.deepEquals(newItem); } }; + + private String idPlus(String id, int delta) { + return new BigInteger(id).add(BigInteger.valueOf(delta)).toString(); + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java index 4678daa0..399e445d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -236,43 +236,35 @@ public final class ViewThreadFragment extends SFragment implements @Override public void onReblog(final boolean reblog, final int position) { final Status status = statuses.get(position); - timelineCases.reblogWithCallback(statuses.get(position), reblog, new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - if (response.isSuccessful()) { - updateStatus(position, response.body()); - eventHub.dispatch(new ReblogEvent(status.getId(), reblog)); - } - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - Log.d(getClass().getSimpleName(), "Failed to reblog status: " + status.getId()); - t.printStackTrace(); - } - }); + timelineCases.reblog(statuses.get(position), reblog) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this))) + .subscribe( + (newStatus) -> updateStatus(position, newStatus), + (t) -> { + Log.d(getClass().getSimpleName(), + "Failed to reblog status: " + status.getId()); + t.printStackTrace(); + } + ); } @Override public void onFavourite(final boolean favourite, final int position) { final Status status = statuses.get(position); - timelineCases.favouriteWithCallback(statuses.get(position), favourite, new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - if (response.isSuccessful()) { - updateStatus(position, response.body()); - eventHub.dispatch(new FavoriteEvent(status.getId(), favourite)); - } - } - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - Log.d(getClass().getSimpleName(), "Failed to favourite status: " + status.getId()); - t.printStackTrace(); - } - }); + timelineCases.favourite(statuses.get(position), favourite) + .observeOn(AndroidSchedulers.mainThread()) + .as(autoDisposable(from(this))) + .subscribe( + (newStatus) -> updateStatus(position, newStatus), + (t) -> { + Log.d(getClass().getSimpleName(), "Failed to favourite status: " + status.getId()); + t.printStackTrace(); + } + ); } private void updateStatus(int position, Status status) { diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java index a837b146..9b48cadb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java @@ -66,6 +66,12 @@ public interface MastodonApi { @Query("since_id") String sinceId, @Query("limit") Integer limit); + @GET("api/v1/timelines/home") + Single> homeTimelineSingle( + @Query("max_id") String maxId, + @Query("since_id") String sinceId, + @Query("limit") Integer limit); + @GET("api/v1/timelines/public") Call> publicTimeline( @Query("local") Boolean local, @@ -146,16 +152,16 @@ public interface MastodonApi { Call deleteStatus(@Path("id") String statusId); @POST("api/v1/statuses/{id}/reblog") - Call reblogStatus(@Path("id") String statusId); + Single reblogStatus(@Path("id") String statusId); @POST("api/v1/statuses/{id}/unreblog") - Call unreblogStatus(@Path("id") String statusId); + Single unreblogStatus(@Path("id") String statusId); @POST("api/v1/statuses/{id}/favourite") - Call favouriteStatus(@Path("id") String statusId); + Single favouriteStatus(@Path("id") String statusId); @POST("api/v1/statuses/{id}/unfavourite") - Call unfavouriteStatus(@Path("id") String statusId); + Single unfavouriteStatus(@Path("id") String statusId); @POST("api/v1/statuses/{id}/pin") Single pinStatus(@Path("id") String statusId); diff --git a/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt b/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt index fe900870..32fe5c07 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt @@ -15,12 +15,10 @@ package com.keylesspalace.tusky.network -import com.keylesspalace.tusky.appstore.BlockEvent -import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.MuteEvent -import com.keylesspalace.tusky.appstore.StatusDeletedEvent +import com.keylesspalace.tusky.appstore.* import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Status +import io.reactivex.Single import io.reactivex.disposables.CompositeDisposable import io.reactivex.rxkotlin.addTo import okhttp3.ResponseBody @@ -33,8 +31,8 @@ import retrofit2.Response */ interface TimelineCases { - fun reblogWithCallback(status: Status, reblog: Boolean, callback: Callback) - fun favouriteWithCallback(status: Status, favourite: Boolean, callback: Callback) + fun reblog(status: Status, reblog: Boolean): Single + fun favourite(status: Status, favourite: Boolean): Single fun mute(id: String) fun block(id: String) fun delete(id: String) @@ -52,7 +50,7 @@ class TimelineCasesImpl( */ private val cancelDisposable = CompositeDisposable() - override fun reblogWithCallback(status: Status, reblog: Boolean, callback: Callback) { + override fun reblog(status: Status, reblog: Boolean): Single { val id = status.actionableId val call = if (reblog) { @@ -60,10 +58,12 @@ class TimelineCasesImpl( } else { mastodonApi.unreblogStatus(id) } - call.enqueue(callback) + return call.doAfterSuccess { + eventHub.dispatch(ReblogEvent(status.id, reblog)) + } } - override fun favouriteWithCallback(status: Status, favourite: Boolean, callback: Callback) { + override fun favourite(status: Status, favourite: Boolean): Single { val id = status.actionableId val call = if (favourite) { @@ -71,7 +71,9 @@ class TimelineCasesImpl( } else { mastodonApi.unfavouriteStatus(id) } - call.enqueue(callback) + return call.doAfterSuccess { + eventHub.dispatch(FavoriteEvent(status.id, favourite)) + } } override fun mute(id: String) { diff --git a/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt new file mode 100644 index 00000000..116bf497 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt @@ -0,0 +1,404 @@ +package com.keylesspalace.tusky.repository + +import android.text.SpannedString +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.keylesspalace.tusky.db.* +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.repository.TimelineRequestMode.DISK +import com.keylesspalace.tusky.repository.TimelineRequestMode.NETWORK +import com.keylesspalace.tusky.util.Either +import com.keylesspalace.tusky.util.HtmlUtils +import io.reactivex.Single +import io.reactivex.schedulers.Schedulers +import java.io.IOException +import java.math.BigInteger +import java.util.* +import java.util.concurrent.TimeUnit + +data class Placeholder(val id: String) + +typealias TimelineStatus = Either + +enum class TimelineRequestMode { + DISK, NETWORK, ANY +} + +interface TimelineRepository { + fun getStatuses(maxId: String?, sinceId: String?, limit: Int, + requestMode: TimelineRequestMode): Single> + + companion object { + val CLEANUP_INTERVAL = TimeUnit.DAYS.toMillis(14) + } +} + +class TimelineRepositoryImpl( + private val timelineDao: TimelineDao, + private val mastodonApi: MastodonApi, + private val accountManager: AccountManager, + private val gson: Gson +) : TimelineRepository { + + init { + this.cleanup() + } + + override fun getStatuses(maxId: String?, sinceId: String?, limit: Int, + requestMode: TimelineRequestMode): Single> { + val acc = accountManager.activeAccount ?: throw IllegalStateException() + val accountId = acc.id + val instance = acc.domain + + return if (requestMode == DISK) { + this.getStatusesFromDb(accountId, maxId, sinceId, limit) + } else { + getStatusesFromNetwork(maxId, sinceId, limit, instance, accountId, requestMode) + } + } + + private fun getStatusesFromNetwork(maxId: String?, sinceId: String?, limit: Int, + instance: String, accountId: Long, + requestMode: TimelineRequestMode + ): Single> { + val maxIdInc = maxId?.let { this.incId(it, 1) } + val sinceIdDec = sinceId?.let { this.incId(it, -1) } + return mastodonApi.homeTimelineSingle(maxIdInc, sinceIdDec, limit + 2) + .doAfterSuccess { statuses -> + this.saveStatusesToDb(instance, accountId, statuses, maxId, sinceId) + } + .map { statuses -> this.removePlaceholdersAndMap(statuses, maxId, sinceId) } + .flatMap { statuses -> + this.addFromDbIfNeeded(accountId, statuses, maxId, sinceId, limit, requestMode) + } + .onErrorResumeNext { error -> + if (error is IOException && requestMode != NETWORK) { + this.getStatusesFromDb(accountId, maxId, sinceId, limit) + } else { + Single.error(error) + } + } + } + + private fun removePlaceholdersAndMap(statuses: List, maxId: String?, + sinceId: String? + ): List> { + val statusesCopy = statuses.toMutableList() + + // Remove first and last statuses if they were used used just for overlap + if (maxId != null && statusesCopy.firstOrNull()?.id == maxId) { + statusesCopy.removeAt(0) + } + if (sinceId != null && statusesCopy.lastOrNull()?.id == sinceId) { + statusesCopy.removeAt(statusesCopy.size - 1) + } + + return statusesCopy.map { s -> Either.Right(s) } + } + + private fun addFromDbIfNeeded(accountId: Long, statuses: List>, + maxId: String?, sinceId: String?, limit: Int, + requestMode: TimelineRequestMode + ): Single>? { + return if (requestMode != NETWORK && statuses.size < 2) { + val newMaxID = if (statuses.isEmpty()) { + maxId + } else { + // It's statuses from network. They're always Right + statuses.last().asRight().id + } + this.getStatusesFromDb(accountId, newMaxID, sinceId, limit) + .map { fromDb -> + // If it's just placeholders and less than limit (so we exhausted both + // db and server at this point) + if (fromDb.size < limit && fromDb.all { !it.isRight() }) { + statuses + } else { + statuses + fromDb + } + } + } else { + Single.just(statuses) + } + } + + private fun getStatusesFromDb(accountId: Long, maxId: String?, sinceId: String?, + limit: Int): Single> { + return timelineDao.getStatusesForAccount(accountId, maxId, sinceId, limit) + .subscribeOn(Schedulers.io()) + .map { statuses -> + statuses.map { it.toStatus() } + } + } + + private fun saveStatusesToDb(instance: String, accountId: Long, statuses: List, + maxId: String?, sinceId: String?) { + Single.fromCallable { + val (prepend, append) = calculatePlaceholders(maxId, sinceId, statuses) + + if (prepend != null) { + timelineDao.insertStatusIfNotThere(prepend.toEntity(accountId)) + } + + if (append != null) { + timelineDao.insertStatusIfNotThere(append.toEntity(accountId)) + } + + for (status in statuses) { + timelineDao.insertInTransaction( + status.toEntity(accountId, instance), + status.account.toEntity(instance, accountId), + status.reblog?.account?.toEntity(instance, accountId) + ) + } + + // There may be placeholders which we thought could be from our TL but they are not + if (statuses.size > 2) { + timelineDao.removeAllPlaceholdersBetween(accountId, statuses.first().id, + statuses.last().id) + } else if (maxId != null && sinceId != null) { + timelineDao.removeAllPlaceholdersBetween(accountId, maxId, sinceId) + } + } + .subscribeOn(Schedulers.io()) + .subscribe() + + } + + private fun calculatePlaceholders(maxId: String?, sinceId: String?, + statuses: List + ): Pair { + if (statuses.isEmpty()) return null to null + + val firstId = statuses.first().id + val prepend = if (maxId != null) { + if (maxId > firstId) { + val decMax = this.incId(maxId, -1) + if (decMax != firstId) { + Placeholder(decMax) + } else null + } else null + } else { + // Placeholders never overwrite real values so it's safe + Placeholder(incId(firstId, 1)) + } + + val lastId = statuses.last().id + val append = if (sinceId != null) { + if (sinceId < lastId) { + val incSince = this.incId(sinceId, 1) + if (incSince != lastId) { + Placeholder(incSince) + } else null + } else null + } else { + // Placeholders never overwrite real values so it's safe + Placeholder(incId(lastId, -1)) + } + + return prepend to append + } + + private fun cleanup() { + Single.fromCallable { + val olderThan = System.currentTimeMillis() - TimelineRepository.CLEANUP_INTERVAL + for (account in accountManager.getAllAccountsOrderedByActive()) { + timelineDao.cleanup(account.id, account.accountId, olderThan) + } + } + .subscribeOn(Schedulers.io()) + .subscribe() + } + + private fun Account.toEntity(instance: String, accountId: Long): TimelineAccountEntity { + return TimelineAccountEntity( + serverId = id, + timelineUserId = accountId, + instance = instance, + localUsername = localUsername, + username = username, + displayName = displayName, + url = url, + avatar = avatar, + emojis = gson.toJson(emojis) + ) + } + + private fun TimelineAccountEntity.toAccount(): Account { + return Account( + id = serverId, + localUsername = localUsername, + username = username, + displayName = displayName, + note = SpannedString(""), + url = url, + avatar = avatar, + header = "", + locked = false, + followingCount = 0, + followersCount = 0, + statusesCount = 0, + source = null, + bot = false, + emojis = gson.fromJson(this.emojis, emojisListTypeToken.type), + fields = null, + moved = null + ) + } + + private fun TimelineStatusWithAccount.toStatus(): TimelineStatus { + if (this.status.authorServerId == null) { + return Either.Left(Placeholder(this.status.serverId)) + } + + val attachments: List = gson.fromJson(status.attachments, + object : TypeToken>() {}.type) ?: listOf() + val mentions: Array = gson.fromJson(status.mentions, + Array::class.java) ?: arrayOf() + val application = gson.fromJson(status.application, Status.Application::class.java) + val emojis: List = gson.fromJson(status.emojis, + object : TypeToken>() {}.type) ?: listOf() + + val reblog = status.reblogServerId?.let { id -> + Status( + id = id, + url = status.url, + account = account.toAccount(), + inReplyToId = status.inReplyToId, + inReplyToAccountId = status.inReplyToAccountId, + reblog = null, + content = HtmlUtils.fromHtml(status.content), + createdAt = Date(status.createdAt), + emojis = emojis, + reblogsCount = status.reblogsCount, + favouritesCount = status.favouritesCount, + reblogged = status.reblogged, + favourited = status.favourited, + sensitive = status.sensitive, + spoilerText = status.spoilerText!!, + visibility = status.visibility!!, + attachments = attachments, + mentions = mentions, + application = application, + pinned = false + + ) + } + val status = if (reblog != null) { + Status( + id = status.serverId, + url = null, // no url for reblogs + account = this.reblogAccount!!.toAccount(), + inReplyToId = null, + inReplyToAccountId = null, + reblog = reblog, + content = SpannedString(""), + createdAt = Date(status.createdAt), // lie but whatever? + emojis = listOf(), + reblogsCount = 0, + favouritesCount = 0, + reblogged = false, + favourited = false, + sensitive = false, + spoilerText = "", + visibility = status.visibility!!, + attachments = listOf(), + mentions = arrayOf(), + application = null, + pinned = false + ) + } else { + Status( + id = status.serverId, + url = status.url, + account = account.toAccount(), + inReplyToId = status.inReplyToId, + inReplyToAccountId = status.inReplyToAccountId, + reblog = null, + content = HtmlUtils.fromHtml(status.content), + createdAt = Date(status.createdAt), + emojis = emojis, + reblogsCount = status.reblogsCount, + favouritesCount = status.favouritesCount, + reblogged = status.reblogged, + favourited = status.favourited, + sensitive = status.sensitive, + spoilerText = status.spoilerText!!, + visibility = status.visibility!!, + attachments = attachments, + mentions = mentions, + application = application, + pinned = false + ) + } + return Either.Right(status) + } + + private fun Status.toEntity(timelineUserId: Long, instance: String): TimelineStatusEntity { + val actionable = actionableStatus + return TimelineStatusEntity( + serverId = this.id, + url = actionable.url!!, + instance = instance, + timelineUserId = timelineUserId, + authorServerId = actionable.account.id, + inReplyToId = actionable.inReplyToId, + inReplyToAccountId = actionable.inReplyToAccountId, + content = HtmlUtils.toHtml(actionable.content), + createdAt = actionable.createdAt.time, + emojis = actionable.emojis.let(gson::toJson), + reblogsCount = actionable.reblogsCount, + favouritesCount = actionable.favouritesCount, + reblogged = actionable.reblogged, + favourited = actionable.favourited, + sensitive = actionable.sensitive, + spoilerText = actionable.spoilerText, + visibility = actionable.visibility, + attachments = actionable.attachments.let(gson::toJson), + mentions = actionable.mentions.let(gson::toJson), + application = actionable.let(gson::toJson), + reblogServerId = reblog?.id, + reblogAccountId = reblog?.let { this.account.id } + ) + } + + private fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { + return TimelineStatusEntity( + serverId = this.id, + url = null, + instance = null, + timelineUserId = timelineUserId, + authorServerId = null, + inReplyToId = null, + inReplyToAccountId = null, + content = null, + createdAt = 0L, + emojis = null, + reblogsCount = 0, + favouritesCount = 0, + reblogged = false, + favourited = false, + sensitive = false, + spoilerText = null, + visibility = null, + attachments = null, + mentions = null, + application = null, + reblogServerId = null, + reblogAccountId = null + + ) + } + + private fun incId(id: String, value: Long): String { + return BigInteger(id).add(BigInteger.valueOf(value)).toString() + } + + companion object { + private val emojisListTypeToken = object : TypeToken>() {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Either.java b/app/src/main/java/com/keylesspalace/tusky/util/Either.java index 32c5406b..e69de29b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/Either.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/Either.java @@ -1,125 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ -package com.keylesspalace.tusky.util; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -/** - * Created by charlag on 05/11/17. - * - * Class to represent sum type/tagged union/variant/ADT e.t.c. - * It is either Left or Right. - */ -public final class Either { - - /** - * Constructs Left instance of either - * @param left Object to be considered Left - * @param Left type - * @param Right type - * @return new instance of Either which contains left. - */ - public static Either left(L left) { - return new Either<>(left, false); - } - - /** - * Constructs Right instance of either - * @param right Object to be considered Right - * @param Left type - * @param Right type - * @return new instance of Either which contains right. - */ - public static Either right(R right) { - return new Either<>(right, true); - } - - private final Object value; - // we need it because of the types erasure - private boolean isRight; - - private Either(Object value, boolean isRight) { - this.value = value; - this.isRight = isRight; - } - - public boolean isRight() { - return isRight; - } - - /** - * Try to get contained object as a Left or throw an exception. - * @throws AssertionError If contained value is Right - * @return contained value as Right - */ - public @NonNull L getAsLeft() { - if (isRight) { - throw new AssertionError("Tried to get the Either as Left while it is Right"); - } - //noinspection unchecked - return (L) value; - } - - /** - * Try to get contained object as a Right or throw an exception. - * @throws AssertionError If contained value is Left - * @return contained value as Right - */ - public @NonNull R getAsRight() { - if (!isRight) { - throw new AssertionError("Tried to get the Either as Right while it is Left"); - } - //noinspection unchecked - return (R) value; - } - - /** - * Same as {@link #getAsLeft()} but returns {@code null} is the value if Right instead of - * throwing an exception. - * @return contained value as Left or null - */ - public @Nullable L getAsLeftOrNull() { - if (isRight) { - return null; - } - //noinspection unchecked - return (L) value; - } - - /** - * Same as {@link #getAsRight()} but returns {@code null} is the value if Left instead of - * throwing an exception. - * @return contained value as Right or null - */ - public @Nullable R getAsRightOrNull() { - if (!isRight) { - return null; - } - //noinspection unchecked - return (R) value; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) return true; - if (obj == null) return false; - if (!(obj instanceof Either)) return false; - Either that = (Either) obj; - return this.isRight == that.isRight && - (this.value == that.value || - this.value != null && this.value.equals(that.value)); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Either.kt b/app/src/main/java/com/keylesspalace/tusky/util/Either.kt new file mode 100644 index 00000000..d4247d83 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/Either.kt @@ -0,0 +1,37 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util + +/** + * Created by charlag on 05/11/17. + * + * Class to represent sum type/tagged union/variant/ADT e.t.c. + * It is either Left or Right. + */ +sealed class Either { + data class Left(val value: L) : Either() + data class Right(val value: R) : Either() + + fun isRight() = this is Right + + fun asLeftOrNull() = (this as? Left)?.value + + fun asRightOrNull() = (this as? Right)?.value + + fun asLeft(): L = (this as Left).value + + fun asRight(): R = (this as Right).value +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.java index ff644fe0..efd55110 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.java @@ -18,16 +18,21 @@ package com.keylesspalace.tusky.util; import androidx.annotation.Nullable; import java.util.ArrayList; +import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; public class ListUtils { - /** @return true if list is null or else return list.isEmpty() */ + /** + * @return true if list is null or else return list.isEmpty() + */ public static boolean isEmpty(@Nullable List list) { return list == null || list.isEmpty(); } - /** @return a new ArrayList containing the elements without duplicates in the same order */ + /** + * @return a new ArrayList containing the elements without duplicates in the same order + */ public static ArrayList removeDuplicates(List list) { LinkedHashSet set = new LinkedHashSet<>(list); return new ArrayList<>(set); diff --git a/app/src/main/java/com/keylesspalace/tusky/view/EndlessOnScrollListener.java b/app/src/main/java/com/keylesspalace/tusky/view/EndlessOnScrollListener.java index fa786c6f..50f9ea6f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/EndlessOnScrollListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/view/EndlessOnScrollListener.java @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.view; +import androidx.annotation.NonNull; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -29,7 +30,7 @@ public abstract class EndlessOnScrollListener extends RecyclerView.OnScrollListe } @Override - public void onScrolled(RecyclerView view, int dx, int dy) { + public void onScrolled(@NonNull RecyclerView view, int dx, int dy) { int totalItemCount = layoutManager.getItemCount(); int lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition(); if (totalItemCount < previousTotalItemCount) { diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt index aea0582d..0a5b3617 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt @@ -16,7 +16,7 @@ data class AttachmentViewData( fun list(status: Status): List { val actionable = status.actionableStatus return actionable.attachments.map { - AttachmentViewData(it, actionable.id, actionable.url) + AttachmentViewData(it, actionable.id, actionable.url!!) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java index b6b481eb..7f0489e2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java @@ -331,9 +331,9 @@ public abstract class StatusViewData { public static final class Placeholder extends StatusViewData { private final boolean isLoading; - private final long id; + private final String id; - public Placeholder(long id, boolean isLoading) { + public Placeholder(String id, boolean isLoading) { this.id = id; this.isLoading = isLoading; } @@ -342,18 +342,18 @@ public abstract class StatusViewData { return isLoading; } - public long getId() { + public String getId() { return id; } @Override public long getViewDataId() { - return id; + return id.hashCode(); } @Override public boolean deepEquals(StatusViewData other) { if (!(other instanceof Placeholder)) return false; Placeholder that = (Placeholder) other; - return isLoading == that.isLoading && id == that.id; + return isLoading == that.isLoading && id.equals(that.id); } @Override public boolean equals(Object o) { @@ -365,9 +365,10 @@ public abstract class StatusViewData { return deepEquals(that); } - @Override public int hashCode() { + @Override + public int hashCode() { int result = (isLoading ? 1 : 0); - result = 31 * result + (int) (id ^ (id >>> 32)); + result = 31 * result + id.hashCode(); return result; } }