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 bugsmain
parent
3c754e1509
commit
cec5444e22
@ -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) |
||||
} |
@ -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() |
||||
} |
||||
} |
@ -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 |
||||
} |
@ -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) |
||||
} |
||||
} |
@ -0,0 +1,404 @@ |
||||
package com.keylesspalace.tusky.repository |
||||
|
||||
import android.text.SpannedString |
||||
import com.google.gson.Gson |
||||
import com.google.gson.reflect.TypeToken |
||||
import com.keylesspalace.tusky.db.* |
||||
import com.keylesspalace.tusky.entity.Account |
||||
import com.keylesspalace.tusky.entity.Attachment |
||||
import com.keylesspalace.tusky.entity.Emoji |
||||
import com.keylesspalace.tusky.entity.Status |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import com.keylesspalace.tusky.repository.TimelineRequestMode.DISK |
||||
import com.keylesspalace.tusky.repository.TimelineRequestMode.NETWORK |
||||
import com.keylesspalace.tusky.util.Either |
||||
import com.keylesspalace.tusky.util.HtmlUtils |
||||
import io.reactivex.Single |
||||
import io.reactivex.schedulers.Schedulers |
||||
import java.io.IOException |
||||
import java.math.BigInteger |
||||
import java.util.* |
||||
import java.util.concurrent.TimeUnit |
||||
|
||||
data class Placeholder(val id: String) |
||||
|
||||
typealias TimelineStatus = Either<Placeholder, Status> |
||||
|
||||
enum class TimelineRequestMode { |
||||
DISK, NETWORK, ANY |
||||
} |
||||
|
||||
interface TimelineRepository { |
||||
fun getStatuses(maxId: String?, sinceId: String?, limit: Int, |
||||
requestMode: TimelineRequestMode): Single<out List<TimelineStatus>> |
||||
|
||||
companion object { |
||||
val CLEANUP_INTERVAL = TimeUnit.DAYS.toMillis(14) |
||||
} |
||||
} |
||||
|
||||
class TimelineRepositoryImpl( |
||||
private val timelineDao: TimelineDao, |
||||
private val mastodonApi: MastodonApi, |
||||
private val accountManager: AccountManager, |
||||
private val gson: Gson |
||||
) : TimelineRepository { |
||||
|
||||
init { |
||||
this.cleanup() |
||||
} |
||||
|
||||
override fun getStatuses(maxId: String?, sinceId: String?, limit: Int, |
||||
requestMode: TimelineRequestMode): Single<out List<TimelineStatus>> { |
||||
val acc = accountManager.activeAccount ?: throw IllegalStateException() |
||||
val accountId = acc.id |
||||
val instance = acc.domain |
||||
|
||||
return if (requestMode == DISK) { |
||||
this.getStatusesFromDb(accountId, maxId, sinceId, limit) |
||||
} else { |
||||
getStatusesFromNetwork(maxId, sinceId, limit, instance, accountId, requestMode) |
||||
} |
||||
} |
||||
|
||||
private fun getStatusesFromNetwork(maxId: String?, sinceId: String?, limit: Int, |
||||
instance: String, accountId: Long, |
||||
requestMode: TimelineRequestMode |
||||
): Single<out List<TimelineStatus>> { |
||||
val maxIdInc = maxId?.let { this.incId(it, 1) } |
||||
val sinceIdDec = sinceId?.let { this.incId(it, -1) } |
||||
return mastodonApi.homeTimelineSingle(maxIdInc, sinceIdDec, limit + 2) |
||||
.doAfterSuccess { statuses -> |
||||
this.saveStatusesToDb(instance, accountId, statuses, maxId, sinceId) |
||||
} |
||||
.map { statuses -> this.removePlaceholdersAndMap(statuses, maxId, sinceId) } |
||||
.flatMap { statuses -> |
||||
this.addFromDbIfNeeded(accountId, statuses, maxId, sinceId, limit, requestMode) |
||||
} |
||||
.onErrorResumeNext { error -> |
||||
if (error is IOException && requestMode != NETWORK) { |
||||
this.getStatusesFromDb(accountId, maxId, sinceId, limit) |
||||
} else { |
||||
Single.error(error) |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun removePlaceholdersAndMap(statuses: List<Status>, maxId: String?, |
||||
sinceId: String? |
||||
): List<Either.Right<Placeholder, Status>> { |
||||
val statusesCopy = statuses.toMutableList() |
||||
|
||||
// Remove first and last statuses if they were used used just for overlap |
||||
if (maxId != null && statusesCopy.firstOrNull()?.id == maxId) { |
||||
statusesCopy.removeAt(0) |
||||
} |
||||
if (sinceId != null && statusesCopy.lastOrNull()?.id == sinceId) { |
||||
statusesCopy.removeAt(statusesCopy.size - 1) |
||||
} |
||||
|
||||
return statusesCopy.map { s -> Either.Right<Placeholder, Status>(s) } |
||||
} |
||||
|
||||
private fun addFromDbIfNeeded(accountId: Long, statuses: List<Either<Placeholder, Status>>, |
||||
maxId: String?, sinceId: String?, limit: Int, |
||||
requestMode: TimelineRequestMode |
||||
): Single<List<TimelineStatus>>? { |
||||
return if (requestMode != NETWORK && statuses.size < 2) { |
||||
val newMaxID = if (statuses.isEmpty()) { |
||||
maxId |
||||
} else { |
||||
// It's statuses from network. They're always Right |
||||
statuses.last().asRight().id |
||||
} |
||||
this.getStatusesFromDb(accountId, newMaxID, sinceId, limit) |
||||
.map { fromDb -> |
||||
// If it's just placeholders and less than limit (so we exhausted both |
||||
// db and server at this point) |
||||
if (fromDb.size < limit && fromDb.all { !it.isRight() }) { |
||||
statuses |
||||
} else { |
||||
statuses + fromDb |
||||
} |
||||
} |
||||
} else { |
||||
Single.just(statuses) |
||||
} |
||||
} |
||||
|
||||
private fun getStatusesFromDb(accountId: Long, maxId: String?, sinceId: String?, |
||||
limit: Int): Single<out List<TimelineStatus>> { |
||||
return timelineDao.getStatusesForAccount(accountId, maxId, sinceId, limit) |
||||
.subscribeOn(Schedulers.io()) |
||||
.map { statuses -> |
||||
statuses.map { it.toStatus() } |
||||
} |
||||
} |
||||
|
||||
private fun saveStatusesToDb(instance: String, accountId: Long, statuses: List<Status>, |
||||
maxId: String?, sinceId: String?) { |
||||
Single.fromCallable { |
||||
val (prepend, append) = calculatePlaceholders(maxId, sinceId, statuses) |
||||
|
||||
if (prepend != null) { |
||||
timelineDao.insertStatusIfNotThere(prepend.toEntity(accountId)) |
||||
} |
||||
|
||||
if (append != null) { |
||||
timelineDao.insertStatusIfNotThere(append.toEntity(accountId)) |
||||
} |
||||
|
||||
for (status in statuses) { |
||||
timelineDao.insertInTransaction( |
||||
status.toEntity(accountId, instance), |
||||
status.account.toEntity(instance, accountId), |
||||
status.reblog?.account?.toEntity(instance, accountId) |
||||
) |
||||
} |
||||
|
||||
// There may be placeholders which we thought could be from our TL but they are not |
||||
if (statuses.size > 2) { |
||||
timelineDao.removeAllPlaceholdersBetween(accountId, statuses.first().id, |
||||
statuses.last().id) |
||||
} else if (maxId != null && sinceId != null) { |
||||
timelineDao.removeAllPlaceholdersBetween(accountId, maxId, sinceId) |
||||
} |
||||
} |
||||
.subscribeOn(Schedulers.io()) |
||||
.subscribe() |
||||
|
||||
} |
||||
|
||||
private fun calculatePlaceholders(maxId: String?, sinceId: String?, |
||||
statuses: List<Status> |
||||
): Pair<Placeholder?, Placeholder?> { |
||||
if (statuses.isEmpty()) return null to null |
||||
|
||||
val firstId = statuses.first().id |
||||
val prepend = if (maxId != null) { |
||||
if (maxId > firstId) { |
||||
val decMax = this.incId(maxId, -1) |
||||
if (decMax != firstId) { |
||||
Placeholder(decMax) |
||||
} else null |
||||
} else null |
||||
} else { |
||||
// Placeholders never overwrite real values so it's safe |
||||
Placeholder(incId(firstId, 1)) |
||||
} |
||||
|
||||
val lastId = statuses.last().id |
||||
val append = if (sinceId != null) { |
||||
if (sinceId < lastId) { |
||||
val incSince = this.incId(sinceId, 1) |
||||
if (incSince != lastId) { |
||||
Placeholder(incSince) |
||||
} else null |
||||
} else null |
||||
} else { |
||||
// Placeholders never overwrite real values so it's safe |
||||
Placeholder(incId(lastId, -1)) |
||||
} |
||||
|
||||
return prepend to append |
||||
} |
||||
|
||||
private fun cleanup() { |
||||
Single.fromCallable { |
||||
val olderThan = System.currentTimeMillis() - TimelineRepository.CLEANUP_INTERVAL |
||||
for (account in accountManager.getAllAccountsOrderedByActive()) { |
||||
timelineDao.cleanup(account.id, account.accountId, olderThan) |
||||
} |
||||
} |
||||
.subscribeOn(Schedulers.io()) |
||||
.subscribe() |
||||
} |
||||
|
||||
private fun Account.toEntity(instance: String, accountId: Long): TimelineAccountEntity { |
||||
return TimelineAccountEntity( |
||||
serverId = id, |
||||
timelineUserId = accountId, |
||||
instance = instance, |
||||
localUsername = localUsername, |
||||
username = username, |
||||
displayName = displayName, |
||||
url = url, |
||||
avatar = avatar, |
||||
emojis = gson.toJson(emojis) |
||||
) |
||||
} |
||||
|
||||
private fun TimelineAccountEntity.toAccount(): Account { |
||||
return Account( |
||||
id = serverId, |
||||
localUsername = localUsername, |
||||
username = username, |
||||
displayName = displayName, |
||||
note = SpannedString(""), |
||||
url = url, |
||||
avatar = avatar, |
||||
header = "", |
||||
locked = false, |
||||
followingCount = 0, |
||||
followersCount = 0, |
||||
statusesCount = 0, |
||||
source = null, |
||||
bot = false, |
||||
emojis = gson.fromJson(this.emojis, emojisListTypeToken.type), |
||||
fields = null, |
||||
moved = null |
||||
) |
||||
} |
||||
|
||||
private fun TimelineStatusWithAccount.toStatus(): TimelineStatus { |
||||
if (this.status.authorServerId == null) { |
||||
return Either.Left(Placeholder(this.status.serverId)) |
||||
} |
||||
|
||||
val attachments: List<Attachment> = gson.fromJson(status.attachments, |
||||
object : TypeToken<List<Attachment>>() {}.type) ?: listOf() |
||||
val mentions: Array<Status.Mention> = gson.fromJson(status.mentions, |
||||
Array<Status.Mention>::class.java) ?: arrayOf() |
||||
val application = gson.fromJson(status.application, Status.Application::class.java) |
||||
val emojis: List<Emoji> = gson.fromJson(status.emojis, |
||||
object : TypeToken<List<Emoji>>() {}.type) ?: listOf() |
||||
|
||||
val reblog = status.reblogServerId?.let { id -> |
||||
Status( |
||||
id = id, |
||||
url = status.url, |
||||
account = account.toAccount(), |
||||
inReplyToId = status.inReplyToId, |
||||
inReplyToAccountId = status.inReplyToAccountId, |
||||
reblog = null, |
||||
content = HtmlUtils.fromHtml(status.content), |
||||
createdAt = Date(status.createdAt), |
||||
emojis = emojis, |
||||
reblogsCount = status.reblogsCount, |
||||
favouritesCount = status.favouritesCount, |
||||
reblogged = status.reblogged, |
||||
favourited = status.favourited, |
||||
sensitive = status.sensitive, |
||||
spoilerText = status.spoilerText!!, |
||||
visibility = status.visibility!!, |
||||
attachments = attachments, |
||||
mentions = mentions, |
||||
application = application, |
||||
pinned = false |
||||
|
||||
) |
||||
} |
||||
val status = if (reblog != null) { |
||||
Status( |
||||
id = status.serverId, |
||||
url = null, // no url for reblogs |
||||
account = this.reblogAccount!!.toAccount(), |
||||
inReplyToId = null, |
||||
inReplyToAccountId = null, |
||||
reblog = reblog, |
||||
content = SpannedString(""), |
||||
createdAt = Date(status.createdAt), // lie but whatever? |
||||
emojis = listOf(), |
||||
reblogsCount = 0, |
||||
favouritesCount = 0, |
||||
reblogged = false, |
||||
favourited = false, |
||||
sensitive = false, |
||||
spoilerText = "", |
||||
visibility = status.visibility!!, |
||||
attachments = listOf(), |
||||
mentions = arrayOf(), |
||||
application = null, |
||||
pinned = false |
||||
) |
||||
} else { |
||||
Status( |
||||
id = status.serverId, |
||||
url = status.url, |
||||
account = account.toAccount(), |
||||
inReplyToId = status.inReplyToId, |
||||
inReplyToAccountId = status.inReplyToAccountId, |
||||
reblog = null, |
||||
content = HtmlUtils.fromHtml(status.content), |
||||
createdAt = Date(status.createdAt), |
||||
emojis = emojis, |
||||
reblogsCount = status.reblogsCount, |
||||
favouritesCount = status.favouritesCount, |
||||
reblogged = status.reblogged, |
||||
favourited = status.favourited, |
||||
sensitive = status.sensitive, |
||||
spoilerText = status.spoilerText!!, |
||||
visibility = status.visibility!!, |
||||
attachments = attachments, |
||||
mentions = mentions, |
||||
application = application, |
||||
pinned = false |
||||
) |
||||
} |
||||
return Either.Right(status) |
||||
} |
||||
|
||||
private fun Status.toEntity(timelineUserId: Long, instance: String): TimelineStatusEntity { |
||||
val actionable = actionableStatus |
||||
return TimelineStatusEntity( |
||||
serverId = this.id, |
||||
url = actionable.url!!, |
||||
instance = instance, |
||||
timelineUserId = timelineUserId, |
||||
authorServerId = actionable.account.id, |
||||
inReplyToId = actionable.inReplyToId, |
||||
inReplyToAccountId = actionable.inReplyToAccountId, |
||||
content = HtmlUtils.toHtml(actionable.content), |
||||
createdAt = actionable.createdAt.time, |
||||
emojis = actionable.emojis.let(gson::toJson), |
||||
reblogsCount = actionable.reblogsCount, |
||||
favouritesCount = actionable.favouritesCount, |
||||
reblogged = actionable.reblogged, |
||||
favourited = actionable.favourited, |
||||
sensitive = actionable.sensitive, |
||||
spoilerText = actionable.spoilerText, |
||||
visibility = actionable.visibility, |
||||
attachments = actionable.attachments.let(gson::toJson), |
||||
mentions = actionable.mentions.let(gson::toJson), |
||||
application = actionable.let(gson::toJson), |
||||
reblogServerId = reblog?.id, |
||||
reblogAccountId = reblog?.let { this.account.id } |
||||
) |
||||
} |
||||
|
||||
private fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { |
||||
return TimelineStatusEntity( |
||||
serverId = this.id, |
||||
url = null, |
||||
instance = null, |
||||
timelineUserId = timelineUserId, |
||||
authorServerId = null, |
||||
inReplyToId = null, |
||||
inReplyToAccountId = null, |
||||
content = null, |
||||
createdAt = 0L, |
||||
emojis = null, |
||||
reblogsCount = 0, |
||||
favouritesCount = 0, |
||||
reblogged = false, |
||||
favourited = false, |
||||
sensitive = false, |
||||
spoilerText = null, |
||||
visibility = null, |
||||
attachments = null, |
||||
mentions = null, |
||||
application = null, |
||||
reblogServerId = null, |
||||
reblogAccountId = null |
||||
|
||||
) |
||||
} |
||||
|
||||
private fun incId(id: String, value: Long): String { |
||||
return BigInteger(id).add(BigInteger.valueOf(value)).toString() |
||||
} |
||||
|
||||
companion object { |
||||
private val emojisListTypeToken = object : TypeToken<List<Emoji>>() {} |
||||
} |
||||
} |
@ -1,125 +0,0 @@ |
||||
/* Copyright 2017 Andrew Dawson |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */
|
||||
package com.keylesspalace.tusky.util; |
||||
|
||||
import androidx.annotation.NonNull; |
||||
import androidx.annotation.Nullable; |
||||
|
||||
/** |
||||
* Created by charlag on 05/11/17. |
||||
* |
||||
* Class to represent sum type/tagged union/variant/ADT e.t.c. |
||||
* It is either Left or Right. |
||||
*/ |
||||
public final class Either<L, R> { |
||||
|
||||
/** |
||||
* Constructs Left instance of either |
||||
* @param left Object to be considered Left |
||||
* @param <L> Left type |
||||
* @param <R> Right type |
||||
* @return new instance of Either which contains left. |
||||
*/ |
||||
public static <L, R> Either<L, R> left(L left) { |
||||
return new Either<>(left, false); |
||||
} |
||||
|
||||
/** |
||||
* Constructs Right instance of either |
||||
* @param right Object to be considered Right |
||||
* @param <L> Left type |
||||
* @param <R> Right type |
||||
* @return new instance of Either which contains right. |
||||
*/ |
||||
public static <L, R> Either<L, R> right(R right) { |
||||
return new Either<>(right, true); |
||||
} |
||||
|
||||
private final Object value; |
||||
// we need it because of the types erasure
|
||||
private boolean isRight; |
||||
|
||||
private Either(Object value, boolean isRight) { |
||||
this.value = value; |
||||
this.isRight = isRight; |
||||
} |
||||
|
||||
public boolean isRight() { |
||||
return isRight; |
||||
} |
||||
|
||||
/** |
||||
* Try to get contained object as a Left or throw an exception. |
||||
* @throws AssertionError If contained value is Right |
||||
* @return contained value as Right |
||||
*/ |
||||
public @NonNull L getAsLeft() { |
||||
if (isRight) { |
||||
throw new AssertionError("Tried to get the Either as Left while it is Right"); |
||||
} |
||||
//noinspection unchecked
|
||||
return (L) value; |
||||
} |
||||
|
||||
/** |
||||
* Try to get contained object as a Right or throw an exception. |
||||
* @throws AssertionError If contained value is Left |
||||
* @return contained value as Right |
||||
*/ |
||||
public @NonNull R getAsRight() { |
||||
if (!isRight) { |
||||
throw new AssertionError("Tried to get the Either as Right while it is Left"); |
||||
} |
||||
//noinspection unchecked
|
||||
return (R) value; |
||||
} |
||||
|
||||
/** |
||||
* Same as {@link #getAsLeft()} but returns {@code null} is the value if Right instead of |
||||
* throwing an exception. |
||||
* @return contained value as Left or null |
||||
*/ |
||||
public @Nullable L getAsLeftOrNull() { |
||||
if (isRight) { |
||||
return null; |
||||
} |
||||
//noinspection unchecked
|
||||
return (L) value; |
||||
} |
||||
|
||||
/** |
||||
* Same as {@link #getAsRight()} but returns {@code null} is the value if Left instead of |
||||
* throwing an exception. |
||||
* @return contained value as Right or null |
||||
*/ |
||||
public @Nullable R getAsRightOrNull() { |
||||
if (!isRight) { |
||||
return null; |
||||
} |
||||
//noinspection unchecked
|
||||
return (R) value; |
||||
} |
||||
|
||||
@Override |
||||
public boolean equals(Object obj) { |
||||
if (this == obj) return true; |
||||
if (obj == null) return false; |
||||
if (!(obj instanceof Either)) return false; |
||||
Either that = (Either) obj; |
||||
return this.isRight == that.isRight && |
||||
(this.value == that.value || |
||||
this.value != null && this.value.equals(that.value)); |
||||
} |
||||
} |
@ -0,0 +1,37 @@ |
||||
/* Copyright 2017 Andrew Dawson |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.util |
||||
|
||||
/** |
||||
* Created by charlag on 05/11/17. |
||||
* |
||||
* Class to represent sum type/tagged union/variant/ADT e.t.c. |
||||
* It is either Left or Right. |
||||
*/ |
||||
sealed class Either<out L, out R> { |
||||
data class Left<out L, out R>(val value: L) : Either<L, R>() |
||||
data class Right<out L, out R>(val value: R) : Either<L, R>() |
||||
|
||||
fun isRight() = this is Right |
||||
|
||||
fun asLeftOrNull() = (this as? Left<L, R>)?.value |
||||
|
||||
fun asRightOrNull() = (this as? Right<L, R>)?.value |
||||
|
||||
fun asLeft(): L = (this as Left<L, R>).value |
||||
|
||||
fun asRight(): R = (this as Right<L, R>).value |
||||
} |
Loading…
Reference in new issue