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