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