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
main
Ivan Kupalov 5 years ago committed by Konrad Pozniak
parent 3c754e1509
commit cec5444e22
  1. 4
      app/build.gradle
  2. 515
      app/schemas/com.keylesspalace.tusky.db.AppDatabase/11.json
  3. 26
      app/src/androidTest/java/com/keylesspalace/tusky/ExampleInstrumentedTest.java
  4. 64
      app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt
  5. 217
      app/src/androidTest/java/com/keylesspalace/tusky/TimelineDAOTest.kt
  6. 5
      app/src/main/java/com/keylesspalace/tusky/MainActivity.java
  7. 2
      app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java
  8. 47
      app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt
  9. 52
      app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java
  10. 87
      app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt
  11. 79
      app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt
  12. 10
      app/src/main/java/com/keylesspalace/tusky/db/TootEntity.java
  13. 3
      app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt
  14. 2
      app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt
  15. 19
      app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt
  16. 2
      app/src/main/java/com/keylesspalace/tusky/entity/Status.kt
  17. 91
      app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java
  18. 82
      app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt
  19. 461
      app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java
  20. 50
      app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java
  21. 14
      app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java
  22. 22
      app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt
  23. 404
      app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt
  24. 125
      app/src/main/java/com/keylesspalace/tusky/util/Either.java
  25. 37
      app/src/main/java/com/keylesspalace/tusky/util/Either.kt
  26. 9
      app/src/main/java/com/keylesspalace/tusky/util/ListUtils.java
  27. 3
      app/src/main/java/com/keylesspalace/tusky/view/EndlessOnScrollListener.java
  28. 2
      app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt
  29. 15
      app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java

@ -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'

@ -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\")"
]
}
}

@ -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 <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@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());
}
}

@ -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))
}
}

@ -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<TimelineStatusEntity, TimelineAccountEntity, TimelineAccountEntity?> {
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)
}

@ -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<Fragment> 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();

@ -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() {

@ -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()
}
}

@ -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`)");
}
};
}

@ -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<List<TimelineStatusWithAccount>>
@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)
}

@ -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
}

@ -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();
}
}
}

@ -34,7 +34,8 @@ import javax.inject.Singleton
ActivitiesModule::class,
ServicesModule::class,
BroadcastReceiverModule::class,
ViewModelModule::class
ViewModelModule::class,
RepositoryModule::class
])
interface AppComponent {
@Component.Builder

@ -86,7 +86,7 @@ class NetworkModule {
@Singleton
fun providesRetrofit(httpClient: OkHttpClient,
converters: @JvmSuppressWildcards Set<Converter.Factory>): 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

@ -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)
}
}

@ -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?,

@ -148,7 +148,7 @@ public class NotificationsFragment extends SFragment implements
@Override
public NotificationViewData apply(Either<Placeholder, Notification> 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<Status>() {
@Override
public void onResponse(@NonNull Call<Status> call, @NonNull retrofit2.Response<Status> response) {
if (response.isSuccessful()) {
setReblogForStatus(position, status, reblog);
}
}
@Override
public void onFailure(@NonNull Call<Status> 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<Status>() {
@Override
public void onResponse(@NonNull Call<Status> call, @NonNull retrofit2.Response<Status> response) {
if (response.isSuccessful()) {
setFavovouriteForStatus(position, status, favourite);
}
}
@Override
public void onFailure(@NonNull Call<Status> 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<Placeholder, Notification> 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<Either<Placeholder, Notification>> iterator = notifications.iterator();
while (iterator.hasNext()) {
Either<Placeholder, Notification> 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<Placeholder, Notification> 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<Notification> 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<Notification, Either<Placeholder, Notification>> notificationLifter =
Either::right;
Either.Right::new;
private List<Either<Placeholder, Notification>> liftNotificationList(List<Notification> list) {
return CollectionUtil.map(list, notificationLifter);
@ -861,7 +852,7 @@ public class NotificationsFragment extends SFragment implements
@Nullable
private Pair<Integer, Notification> 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

@ -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<Status> {
override fun onResponse(call: Call<Status>?, response: Response<Status>?) {
status.reblogged = true
searchAdapter.updateStatusAtPosition(
ViewDataUtils.statusToViewData(
status,
alwaysShowSensitiveMedia
),
position
)
}
override fun onFailure(call: Call<Status>?, 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<Status> {
override fun onResponse(call: Call<Status>?, response: Response<Status>?) {
status.favourited = true
searchAdapter.updateStatusAtPosition(
ViewDataUtils.statusToViewData(
status,
alwaysShowSensitiveMedia
),
position
)
}
override fun onFailure(call: Call<Status>?, 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
}

@ -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<Either<Placeholder, Status>, StatusViewData>() {
@Override
public StatusViewData apply(Either<Placeholder, Status> 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<Placeholder, Status> 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<Placeholder, Status> 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<Placeholder, Status> 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<Status>() {
@Override
public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) {
if (response.isSuccessful()) {
setRebloggedForStatus(position, status, reblog);
}
}
@Override
public void onFailure(@NonNull Call<Status> 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();
timelineCases.favouriteWithCallback(status, favourite, new Callback<Status>() {
@Override
public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) {
if (response.isSuccessful()) {
setFavouriteForStatus(position, status, favourite);
}
}
@Override
public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) {
Log.d(TAG, "Failed to favourite status " + status.getId(), t);
}
});
final Status status = statuses.get(position).asRight();
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<Either<Placeholder, Status>> 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<Placeholder, Status> 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<Either<Placeholder, Status>> iterator =
this.statuses.listIterator(this.statuses.size());
while (iterator.hasPrevious()) {
Either<Placeholder, Status> 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<List<Status>> callback = new Callback<List<Status>>() {
@Override
public void onResponse(@NonNull Call<List<Status>> call, @NonNull Response<List<Status>> 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<List<Status>> callback = new Callback<List<Status>>() {
@Override
public void onResponse(@NonNull Call<List<Status>> call, @NonNull Response<List<Status>> response) {
if (response.isSuccessful()) {
onFetchTimelineSuccess(liftStatusList(response.body()), fetchEnd, pos);
} else {
onFetchTimelineFailure(new Exception(response.message()), fetchEnd, pos);
}
}
@Override
public void onFailure(@NonNull Call<List<Status>> call, @NonNull Throwable t) {
onFetchTimelineFailure((Exception) t, fetchEnd, pos);
}
};
@Override
public void onFailure(@NonNull Call<List<Status>> call, @NonNull Throwable t) {
onFetchTimelineFailure((Exception) t, fetchEnd, pos);
}
};
Call<List<Status>> listCall = getFetchCallByTimelineType(kind, hashtagOrId, fromId, uptoId);
callList.add(listCall);
listCall.enqueue(callback);
Call<List<Status>> listCall = getFetchCallByTimelineType(kind, hashtagOrId, fromId, uptoId);
callList.add(listCall);
listCall.enqueue(callback);
}
}
private void onFetchTimelineSuccess(List<Status> statuses, String linkHeader,
private void onFetchTimelineSuccess(List<Either<Placeholder, Status>> 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<HttpHeaderLink> 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<Status> statuses) {
Iterator<Status> it = statuses.iterator();
private void filterStatuses(List<Either<Placeholder, Status>> statuses) {
Iterator<Either<Placeholder, Status>> 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<Status> newStatuses, @Nullable String fromId,
@Nullable String toId, boolean fullFetch) {
private void updateStatuses(List<Either<Placeholder, Status>> newStatuses, boolean fullFetch) {
if (ListUtils.isEmpty(newStatuses)) {
return;
}
if (fromId != null) {
bottomId = fromId;
}
if (toId != null) {
topId = toId;
}
List<Either<Placeholder, Status>> liftedNew = liftStatusList(newStatuses);
if (statuses.isEmpty()) {
statuses.addAll(liftedNew);
statuses.addAll(newStatuses);
} else {
Either<Placeholder, Status> lastOfNew = liftedNew.get(newStatuses.size() - 1);
Either<Placeholder, Status> 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<Status> 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<Either<Placeholder, Status>> newStatuses) {
if (ListUtils.isEmpty(newStatuses)) {
return;
}
Status last = null;
Either<Placeholder, Status> 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<Status> 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<Either<Placeholder, Status>> statuses) {
CollectionsKt.removeAll(statuses, s -> !s.isRight());
}
private void replacePlaceholderWithStatuses(List<Either<Placeholder, Status>> newStatuses,
boolean fullFetch, int pos) {
Either<Placeholder, Status> placeholder = statuses.get(pos);
if (!placeholder.isRight()) {
statuses.remove(pos);
}
@ -981,29 +1073,20 @@ public class TimelineFragment extends SFragment implements
return;
}
List<Either<Placeholder, Status>> liftedNew = liftStatusList(newStatuses);
if (fullFetch) {
liftedNew.add(Either.left(newPlaceholder()));
newStatuses.add(placeholder);
}
statuses.addAll(pos, liftedNew);
updateAdapter();
statuses.addAll(pos, newStatuses);
removeConsecutivePlaceholders();
}
updateAdapter();
private static boolean findStatus(List<Status> 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<Status, Either<Placeholder, Status>> statusLifter =
Either::right;
Either.Right::new;
private @Nullable
Pair<StatusViewData.Concrete, Integer>
@ -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.<Placeholder, Status>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();
}
}

@ -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<Status>() {
@Override
public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) {
if (response.isSuccessful()) {
updateStatus(position, response.body());
eventHub.dispatch(new ReblogEvent(status.getId(), reblog));
}
}
@Override
public void onFailure(@NonNull Call<Status> 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<Status>() {
@Override
public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) {
if (response.isSuccessful()) {
updateStatus(position, response.body());
eventHub.dispatch(new FavoriteEvent(status.getId(), favourite));
}
}
@Override
public void onFailure(@NonNull Call<Status> 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) {

@ -66,6 +66,12 @@ public interface MastodonApi {
@Query("since_id") String sinceId,
@Query("limit") Integer limit);
@GET("api/v1/timelines/home")
Single<List<Status>> homeTimelineSingle(
@Query("max_id") String maxId,
@Query("since_id") String sinceId,
@Query("limit") Integer limit);
@GET("api/v1/timelines/public")
Call<List<Status>> publicTimeline(
@Query("local") Boolean local,
@ -146,16 +152,16 @@ public interface MastodonApi {
Call<ResponseBody> deleteStatus(@Path("id") String statusId);
@POST("api/v1/statuses/{id}/reblog")
Call<Status> reblogStatus(@Path("id") String statusId);
Single<Status> reblogStatus(@Path("id") String statusId);
@POST("api/v1/statuses/{id}/unreblog")
Call<Status> unreblogStatus(@Path("id") String statusId);
Single<Status> unreblogStatus(@Path("id") String statusId);
@POST("api/v1/statuses/{id}/favourite")
Call<Status> favouriteStatus(@Path("id") String statusId);
Single<Status> favouriteStatus(@Path("id") String statusId);
@POST("api/v1/statuses/{id}/unfavourite")
Call<Status> unfavouriteStatus(@Path("id") String statusId);
Single<Status> unfavouriteStatus(@Path("id") String statusId);
@POST("api/v1/statuses/{id}/pin")
Single<Status> pinStatus(@Path("id") String statusId);

@ -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<Status>)
fun favouriteWithCallback(status: Status, favourite: Boolean, callback: Callback<Status>)
fun reblog(status: Status, reblog: Boolean): Single<Status>
fun favourite(status: Status, favourite: Boolean): Single<Status>
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<Status>) {
override fun reblog(status: Status, reblog: Boolean): Single<Status> {
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<Status>) {
override fun favourite(status: Status, favourite: Boolean): Single<Status> {
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) {

@ -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<Placeholder, Status>
enum class TimelineRequestMode {
DISK, NETWORK, ANY
}
interface TimelineRepository {
fun getStatuses(maxId: String?, sinceId: String?, limit: Int,
requestMode: TimelineRequestMode): Single<out List<TimelineStatus>>
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<out List<TimelineStatus>> {
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<out List<TimelineStatus>> {
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<Status>, maxId: String?,
sinceId: String?
): List<Either.Right<Placeholder, Status>> {
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<Placeholder, Status>(s) }
}
private fun addFromDbIfNeeded(accountId: Long, statuses: List<Either<Placeholder, Status>>,
maxId: String?, sinceId: String?, limit: Int,
requestMode: TimelineRequestMode
): Single<List<TimelineStatus>>? {
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<out List<TimelineStatus>> {
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<Status>,
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<Status>
): Pair<Placeholder?, Placeholder?> {
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<Attachment> = gson.fromJson(status.attachments,
object : TypeToken<List<Attachment>>() {}.type) ?: listOf()
val mentions: Array<Status.Mention> = gson.fromJson(status.mentions,
Array<Status.Mention>::class.java) ?: arrayOf()
val application = gson.fromJson(status.application, Status.Application::class.java)
val emojis: List<Emoji> = gson.fromJson(status.emojis,
object : TypeToken<List<Emoji>>() {}.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<List<Emoji>>() {}
}
}

@ -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 <http://www.gnu.org/licenses>. */
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<L, R> {
/**
* Constructs Left instance of either
* @param left Object to be considered Left
* @param <L> Left type
* @param <R> Right type
* @return new instance of Either which contains left.
*/
public static <L, R> Either<L, R> left(L left) {
return new Either<>(left, false);
}
/**
* Constructs Right instance of either
* @param right Object to be considered Right
* @param <L> Left type
* @param <R> Right type
* @return new instance of Either which contains right.
*/
public static <L, R> Either<L, R> 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));
}
}

@ -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 <http://www.gnu.org/licenses>. */
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<out L, out R> {
data class Left<out L, out R>(val value: L) : Either<L, R>()
data class Right<out L, out R>(val value: R) : Either<L, R>()
fun isRight() = this is Right
fun asLeftOrNull() = (this as? Left<L, R>)?.value
fun asRightOrNull() = (this as? Right<L, R>)?.value
fun asLeft(): L = (this as Left<L, R>).value
fun asRight(): R = (this as Right<L, R>).value
}

@ -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 <T> ArrayList<T> removeDuplicates(List<T> list) {
LinkedHashSet<T> set = new LinkedHashSet<>(list);
return new ArrayList<>(set);

@ -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) {

@ -16,7 +16,7 @@ data class AttachmentViewData(
fun list(status: Status): List<AttachmentViewData> {
val actionable = status.actionableStatus
return actionable.attachments.map {
AttachmentViewData(it, actionable.id, actionable.url)
AttachmentViewData(it, actionable.id, actionable.url!!)
}
}
}

@ -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;
}
}

Loading…
Cancel
Save