mirror of https://github.com/tuskyapp/Tusky.git
Browse Source
* 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 bugspull/989/head
29 changed files with 1951 additions and 498 deletions
@ -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