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