Merge tag 'v19.0' into develop
This commit is contained in:
commit
eeeb6a8599
160 changed files with 6469 additions and 1305 deletions
|
@ -24,8 +24,8 @@ android {
|
|||
applicationId APP_ID
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 31
|
||||
versionCode 87
|
||||
versionName "18.0-CW1"
|
||||
versionCode 94
|
||||
versionName "19.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
|
@ -99,10 +99,10 @@ ext.roomVersion = '2.4.2'
|
|||
ext.retrofitVersion = '2.9.0'
|
||||
ext.okhttpVersion = '4.9.3'
|
||||
ext.glideVersion = '4.13.1'
|
||||
ext.daggerVersion = '2.41'
|
||||
ext.daggerVersion = '2.42'
|
||||
ext.materialdrawerVersion = '8.4.5'
|
||||
ext.emoji2_version = '1.1.0'
|
||||
ext.filemojicompat_version = '3.2.1'
|
||||
ext.filemojicompat_version = '3.2.2'
|
||||
|
||||
// if libraries are changed here, they should also be changed in LicenseActivity
|
||||
dependencies {
|
||||
|
@ -135,14 +135,14 @@ dependencies {
|
|||
kapt "androidx.room:room-compiler:$roomVersion"
|
||||
implementation 'androidx.core:core-splashscreen:1.0.0-beta02'
|
||||
|
||||
implementation "com.google.android.material:material:1.5.0"
|
||||
implementation "com.google.android.material:material:1.6.0"
|
||||
|
||||
implementation "com.google.code.gson:gson:2.9.0"
|
||||
|
||||
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
|
||||
implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"
|
||||
implementation "com.squareup.retrofit2:adapter-rxjava3:$retrofitVersion"
|
||||
implementation "at.connyduck:kotlin-result-calladapter:1.0.1"
|
||||
implementation "at.connyduck:networkresult-calladapter:1.0.0"
|
||||
|
||||
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
|
||||
implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion"
|
||||
|
@ -153,7 +153,7 @@ dependencies {
|
|||
implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion"
|
||||
kapt "com.github.bumptech.glide:compiler:$glideVersion"
|
||||
|
||||
implementation "com.github.penfeizhou.android.animation:glide-plugin:2.20.0"
|
||||
implementation "com.github.penfeizhou.android.animation:glide-plugin:2.22.0"
|
||||
|
||||
implementation "io.reactivex.rxjava3:rxjava:3.1.3"
|
||||
implementation "io.reactivex.rxjava3:rxandroid:3.0.0"
|
||||
|
@ -176,17 +176,22 @@ dependencies {
|
|||
implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion"
|
||||
implementation 'com.mikepenz:google-material-typeface:4.0.0.2-kotlin@aar'
|
||||
|
||||
implementation "com.github.CanHub:Android-Image-Cropper:4.1.0"
|
||||
implementation "com.github.CanHub:Android-Image-Cropper:4.2.1"
|
||||
|
||||
implementation "de.c1710:filemojicompat-ui:$filemojicompat_version"
|
||||
implementation "de.c1710:filemojicompat:$filemojicompat_version"
|
||||
implementation "de.c1710:filemojicompat-defaults:$filemojicompat_version"
|
||||
|
||||
implementation "org.bouncycastle:bcprov-jdk15on:1.70"
|
||||
implementation "com.github.UnifiedPush:android-connector:2.0.0"
|
||||
|
||||
testImplementation "androidx.test.ext:junit:1.1.3"
|
||||
testImplementation "org.robolectric:robolectric:4.4"
|
||||
testImplementation "org.mockito:mockito-inline:4.4.0"
|
||||
testImplementation "org.mockito.kotlin:mockito-kotlin:4.0.0"
|
||||
|
||||
testImplementation "com.squareup.okhttp3:mockwebserver:$okhttpVersion"
|
||||
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0"
|
||||
androidTestImplementation "androidx.room:room-testing:$roomVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:1.1.3"
|
||||
|
|
4
app/proguard-rules.pro
vendored
4
app/proguard-rules.pro
vendored
|
@ -82,6 +82,10 @@
|
|||
-keepattributes SourceFile,LineNumberTable
|
||||
-renamesourcefileattribute SourceFile
|
||||
|
||||
# Bouncy Castle -- Keep EC
|
||||
-keep class org.bouncycastle.jcajce.provider.asymmetric.EC$* { *; }
|
||||
-keep class org.bouncycastle.jcajce.provider.asymmetric.ec.KeyPairGeneratorSpi$EC
|
||||
|
||||
# remove all logging from production apk
|
||||
-assumenosideeffects class android.util.Log {
|
||||
public static *** getStackTraceString(...);
|
||||
|
|
857
app/schemas/com.keylesspalace.tusky.db.AppDatabase/36.json
Normal file
857
app/schemas/com.keylesspalace.tusky.db.AppDatabase/36.json
Normal file
|
@ -0,0 +1,857 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 36,
|
||||
"identityHash": "1b7461c291f67fe0b21f77b95de6a6be",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "DraftEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "inReplyToId",
|
||||
"columnName": "inReplyToId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "content",
|
||||
"columnName": "content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentWarning",
|
||||
"columnName": "contentWarning",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "sensitive",
|
||||
"columnName": "sensitive",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "visibility",
|
||||
"columnName": "visibility",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "poll",
|
||||
"columnName": "poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "failedToSend",
|
||||
"columnName": "failedToSend",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"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, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` 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, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` 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": "notificationsFollowRequested",
|
||||
"columnName": "notificationsFollowRequested",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsReblogged",
|
||||
"columnName": "notificationsReblogged",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsFavorited",
|
||||
"columnName": "notificationsFavorited",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsPolls",
|
||||
"columnName": "notificationsPolls",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsSubscriptions",
|
||||
"columnName": "notificationsSubscriptions",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsSignUps",
|
||||
"columnName": "notificationsSignUps",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsUpdates",
|
||||
"columnName": "notificationsUpdates",
|
||||
"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": "alwaysOpenSpoiler",
|
||||
"columnName": "alwaysOpenSpoiler",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"fieldPath": "tabPreferences",
|
||||
"columnName": "tabPreferences",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsFilter",
|
||||
"columnName": "notificationsFilter",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "oauthScopes",
|
||||
"columnName": "oauthScopes",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "unifiedPushUrl",
|
||||
"columnName": "unifiedPushUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushPubKey",
|
||||
"columnName": "pushPubKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushPrivKey",
|
||||
"columnName": "pushPrivKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushAuth",
|
||||
"columnName": "pushAuth",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushServerKey",
|
||||
"columnName": "pushServerKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_AccountEntity_domain_accountId",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"domain",
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `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, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, 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
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxPollOptions",
|
||||
"columnName": "maxPollOptions",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxPollOptionLength",
|
||||
"columnName": "maxPollOptionLength",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "minPollDuration",
|
||||
"columnName": "minPollDuration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxPollDuration",
|
||||
"columnName": "maxPollDuration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "charactersReservedPerUrl",
|
||||
"columnName": "charactersReservedPerUrl",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "version",
|
||||
"columnName": "version",
|
||||
"affinity": "TEXT",
|
||||
"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, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` 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": "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": "bookmarked",
|
||||
"columnName": "bookmarked",
|
||||
"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": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "visibility",
|
||||
"columnName": "visibility",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mentions",
|
||||
"columnName": "mentions",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"fieldPath": "poll",
|
||||
"columnName": "poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "muted",
|
||||
"columnName": "muted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "expanded",
|
||||
"columnName": "expanded",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentCollapsed",
|
||||
"columnName": "contentCollapsed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentShowing",
|
||||
"columnName": "contentShowing",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pinned",
|
||||
"columnName": "pinned",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "card",
|
||||
"columnName": "card",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"serverId",
|
||||
"timelineUserId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"authorServerId",
|
||||
"timelineUserId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `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, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "serverId",
|
||||
"columnName": "serverId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timelineUserId",
|
||||
"columnName": "timelineUserId",
|
||||
"affinity": "INTEGER",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"fieldPath": "bot",
|
||||
"columnName": "bot",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"serverId",
|
||||
"timelineUserId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "ConversationEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accounts",
|
||||
"columnName": "accounts",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "unread",
|
||||
"columnName": "unread",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.id",
|
||||
"columnName": "s_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.url",
|
||||
"columnName": "s_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.inReplyToId",
|
||||
"columnName": "s_inReplyToId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.inReplyToAccountId",
|
||||
"columnName": "s_inReplyToAccountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.account",
|
||||
"columnName": "s_account",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.content",
|
||||
"columnName": "s_content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.createdAt",
|
||||
"columnName": "s_createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.emojis",
|
||||
"columnName": "s_emojis",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.favouritesCount",
|
||||
"columnName": "s_favouritesCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.favourited",
|
||||
"columnName": "s_favourited",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.bookmarked",
|
||||
"columnName": "s_bookmarked",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.sensitive",
|
||||
"columnName": "s_sensitive",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.spoilerText",
|
||||
"columnName": "s_spoilerText",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.attachments",
|
||||
"columnName": "s_attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.mentions",
|
||||
"columnName": "s_mentions",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.tags",
|
||||
"columnName": "s_tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.showingHiddenContent",
|
||||
"columnName": "s_showingHiddenContent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.expanded",
|
||||
"columnName": "s_expanded",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.collapsed",
|
||||
"columnName": "s_collapsed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.muted",
|
||||
"columnName": "s_muted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.poll",
|
||||
"columnName": "s_poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"accountId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"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, '1b7461c291f67fe0b21f77b95de6a6be')"
|
||||
]
|
||||
}
|
||||
}
|
869
app/schemas/com.keylesspalace.tusky.db.AppDatabase/37.json
Normal file
869
app/schemas/com.keylesspalace.tusky.db.AppDatabase/37.json
Normal file
|
@ -0,0 +1,869 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 37,
|
||||
"identityHash": "11033751d382aa8a1c6fc68833097d35",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "DraftEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "inReplyToId",
|
||||
"columnName": "inReplyToId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "content",
|
||||
"columnName": "content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentWarning",
|
||||
"columnName": "contentWarning",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "sensitive",
|
||||
"columnName": "sensitive",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "visibility",
|
||||
"columnName": "visibility",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "poll",
|
||||
"columnName": "poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "failedToSend",
|
||||
"columnName": "failedToSend",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"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, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` 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, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` 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": "notificationsFollowRequested",
|
||||
"columnName": "notificationsFollowRequested",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsReblogged",
|
||||
"columnName": "notificationsReblogged",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsFavorited",
|
||||
"columnName": "notificationsFavorited",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsPolls",
|
||||
"columnName": "notificationsPolls",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsSubscriptions",
|
||||
"columnName": "notificationsSubscriptions",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsSignUps",
|
||||
"columnName": "notificationsSignUps",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsUpdates",
|
||||
"columnName": "notificationsUpdates",
|
||||
"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": "alwaysOpenSpoiler",
|
||||
"columnName": "alwaysOpenSpoiler",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"fieldPath": "tabPreferences",
|
||||
"columnName": "tabPreferences",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsFilter",
|
||||
"columnName": "notificationsFilter",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "oauthScopes",
|
||||
"columnName": "oauthScopes",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "unifiedPushUrl",
|
||||
"columnName": "unifiedPushUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushPubKey",
|
||||
"columnName": "pushPubKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushPrivKey",
|
||||
"columnName": "pushPrivKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushAuth",
|
||||
"columnName": "pushAuth",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushServerKey",
|
||||
"columnName": "pushServerKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_AccountEntity_domain_accountId",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"domain",
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `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, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, 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
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxPollOptions",
|
||||
"columnName": "maxPollOptions",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxPollOptionLength",
|
||||
"columnName": "maxPollOptionLength",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "minPollDuration",
|
||||
"columnName": "minPollDuration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxPollDuration",
|
||||
"columnName": "maxPollDuration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "charactersReservedPerUrl",
|
||||
"columnName": "charactersReservedPerUrl",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "version",
|
||||
"columnName": "version",
|
||||
"affinity": "TEXT",
|
||||
"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, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` 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": "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": "repliesCount",
|
||||
"columnName": "repliesCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "reblogged",
|
||||
"columnName": "reblogged",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookmarked",
|
||||
"columnName": "bookmarked",
|
||||
"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": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "visibility",
|
||||
"columnName": "visibility",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mentions",
|
||||
"columnName": "mentions",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"fieldPath": "poll",
|
||||
"columnName": "poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "muted",
|
||||
"columnName": "muted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "expanded",
|
||||
"columnName": "expanded",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentCollapsed",
|
||||
"columnName": "contentCollapsed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentShowing",
|
||||
"columnName": "contentShowing",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pinned",
|
||||
"columnName": "pinned",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "card",
|
||||
"columnName": "card",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"serverId",
|
||||
"timelineUserId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"authorServerId",
|
||||
"timelineUserId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `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, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "serverId",
|
||||
"columnName": "serverId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timelineUserId",
|
||||
"columnName": "timelineUserId",
|
||||
"affinity": "INTEGER",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"fieldPath": "bot",
|
||||
"columnName": "bot",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"serverId",
|
||||
"timelineUserId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "ConversationEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accounts",
|
||||
"columnName": "accounts",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "unread",
|
||||
"columnName": "unread",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.id",
|
||||
"columnName": "s_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.url",
|
||||
"columnName": "s_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.inReplyToId",
|
||||
"columnName": "s_inReplyToId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.inReplyToAccountId",
|
||||
"columnName": "s_inReplyToAccountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.account",
|
||||
"columnName": "s_account",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.content",
|
||||
"columnName": "s_content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.createdAt",
|
||||
"columnName": "s_createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.emojis",
|
||||
"columnName": "s_emojis",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.favouritesCount",
|
||||
"columnName": "s_favouritesCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.repliesCount",
|
||||
"columnName": "s_repliesCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.favourited",
|
||||
"columnName": "s_favourited",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.bookmarked",
|
||||
"columnName": "s_bookmarked",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.sensitive",
|
||||
"columnName": "s_sensitive",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.spoilerText",
|
||||
"columnName": "s_spoilerText",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.attachments",
|
||||
"columnName": "s_attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.mentions",
|
||||
"columnName": "s_mentions",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.tags",
|
||||
"columnName": "s_tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.showingHiddenContent",
|
||||
"columnName": "s_showingHiddenContent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.expanded",
|
||||
"columnName": "s_expanded",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.collapsed",
|
||||
"columnName": "s_collapsed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.muted",
|
||||
"columnName": "s_muted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.poll",
|
||||
"columnName": "s_poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"accountId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"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, '11033751d382aa8a1c6fc68833097d35')"
|
||||
]
|
||||
}
|
||||
}
|
875
app/schemas/com.keylesspalace.tusky.db.AppDatabase/38.json
Normal file
875
app/schemas/com.keylesspalace.tusky.db.AppDatabase/38.json
Normal file
|
@ -0,0 +1,875 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 38,
|
||||
"identityHash": "798fc8d34064eb671c079689d4650ea5",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "DraftEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "inReplyToId",
|
||||
"columnName": "inReplyToId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "content",
|
||||
"columnName": "content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentWarning",
|
||||
"columnName": "contentWarning",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "sensitive",
|
||||
"columnName": "sensitive",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "visibility",
|
||||
"columnName": "visibility",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "poll",
|
||||
"columnName": "poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "failedToSend",
|
||||
"columnName": "failedToSend",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"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, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` 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, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` 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": "notificationsFollowRequested",
|
||||
"columnName": "notificationsFollowRequested",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsReblogged",
|
||||
"columnName": "notificationsReblogged",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsFavorited",
|
||||
"columnName": "notificationsFavorited",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsPolls",
|
||||
"columnName": "notificationsPolls",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsSubscriptions",
|
||||
"columnName": "notificationsSubscriptions",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsSignUps",
|
||||
"columnName": "notificationsSignUps",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsUpdates",
|
||||
"columnName": "notificationsUpdates",
|
||||
"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": "alwaysOpenSpoiler",
|
||||
"columnName": "alwaysOpenSpoiler",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"fieldPath": "tabPreferences",
|
||||
"columnName": "tabPreferences",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsFilter",
|
||||
"columnName": "notificationsFilter",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "oauthScopes",
|
||||
"columnName": "oauthScopes",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "unifiedPushUrl",
|
||||
"columnName": "unifiedPushUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushPubKey",
|
||||
"columnName": "pushPubKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushPrivKey",
|
||||
"columnName": "pushPrivKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushAuth",
|
||||
"columnName": "pushAuth",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushServerKey",
|
||||
"columnName": "pushServerKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_AccountEntity_domain_accountId",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"domain",
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `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, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, 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
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxPollOptions",
|
||||
"columnName": "maxPollOptions",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxPollOptionLength",
|
||||
"columnName": "maxPollOptionLength",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "minPollDuration",
|
||||
"columnName": "minPollDuration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxPollDuration",
|
||||
"columnName": "maxPollDuration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "charactersReservedPerUrl",
|
||||
"columnName": "charactersReservedPerUrl",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "version",
|
||||
"columnName": "version",
|
||||
"affinity": "TEXT",
|
||||
"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, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` 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": "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": "repliesCount",
|
||||
"columnName": "repliesCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "reblogged",
|
||||
"columnName": "reblogged",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookmarked",
|
||||
"columnName": "bookmarked",
|
||||
"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": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "visibility",
|
||||
"columnName": "visibility",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mentions",
|
||||
"columnName": "mentions",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"fieldPath": "poll",
|
||||
"columnName": "poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "muted",
|
||||
"columnName": "muted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "expanded",
|
||||
"columnName": "expanded",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentCollapsed",
|
||||
"columnName": "contentCollapsed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentShowing",
|
||||
"columnName": "contentShowing",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pinned",
|
||||
"columnName": "pinned",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "card",
|
||||
"columnName": "card",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"serverId",
|
||||
"timelineUserId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"authorServerId",
|
||||
"timelineUserId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `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, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "serverId",
|
||||
"columnName": "serverId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timelineUserId",
|
||||
"columnName": "timelineUserId",
|
||||
"affinity": "INTEGER",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"fieldPath": "bot",
|
||||
"columnName": "bot",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"serverId",
|
||||
"timelineUserId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "ConversationEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "order",
|
||||
"columnName": "order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accounts",
|
||||
"columnName": "accounts",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "unread",
|
||||
"columnName": "unread",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.id",
|
||||
"columnName": "s_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.url",
|
||||
"columnName": "s_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.inReplyToId",
|
||||
"columnName": "s_inReplyToId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.inReplyToAccountId",
|
||||
"columnName": "s_inReplyToAccountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.account",
|
||||
"columnName": "s_account",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.content",
|
||||
"columnName": "s_content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.createdAt",
|
||||
"columnName": "s_createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.emojis",
|
||||
"columnName": "s_emojis",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.favouritesCount",
|
||||
"columnName": "s_favouritesCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.repliesCount",
|
||||
"columnName": "s_repliesCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.favourited",
|
||||
"columnName": "s_favourited",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.bookmarked",
|
||||
"columnName": "s_bookmarked",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.sensitive",
|
||||
"columnName": "s_sensitive",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.spoilerText",
|
||||
"columnName": "s_spoilerText",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.attachments",
|
||||
"columnName": "s_attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.mentions",
|
||||
"columnName": "s_mentions",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.tags",
|
||||
"columnName": "s_tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.showingHiddenContent",
|
||||
"columnName": "s_showingHiddenContent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.expanded",
|
||||
"columnName": "s_expanded",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.collapsed",
|
||||
"columnName": "s_collapsed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.muted",
|
||||
"columnName": "s_muted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.poll",
|
||||
"columnName": "s_poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"accountId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"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, '798fc8d34064eb671c079689d4650ea5')"
|
||||
]
|
||||
}
|
||||
}
|
887
app/schemas/com.keylesspalace.tusky.db.AppDatabase/39.json
Normal file
887
app/schemas/com.keylesspalace.tusky.db.AppDatabase/39.json
Normal file
|
@ -0,0 +1,887 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 39,
|
||||
"identityHash": "ed3b752a3faec9d092d5ac0a2823d5d5",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "DraftEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "inReplyToId",
|
||||
"columnName": "inReplyToId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "content",
|
||||
"columnName": "content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentWarning",
|
||||
"columnName": "contentWarning",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "sensitive",
|
||||
"columnName": "sensitive",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "visibility",
|
||||
"columnName": "visibility",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "poll",
|
||||
"columnName": "poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "failedToSend",
|
||||
"columnName": "failedToSend",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"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, `clientId` TEXT, `clientSecret` TEXT, `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, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` 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, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` 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": "clientId",
|
||||
"columnName": "clientId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "clientSecret",
|
||||
"columnName": "clientSecret",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"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": "notificationsFollowRequested",
|
||||
"columnName": "notificationsFollowRequested",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsReblogged",
|
||||
"columnName": "notificationsReblogged",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsFavorited",
|
||||
"columnName": "notificationsFavorited",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsPolls",
|
||||
"columnName": "notificationsPolls",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsSubscriptions",
|
||||
"columnName": "notificationsSubscriptions",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsSignUps",
|
||||
"columnName": "notificationsSignUps",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsUpdates",
|
||||
"columnName": "notificationsUpdates",
|
||||
"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": "alwaysOpenSpoiler",
|
||||
"columnName": "alwaysOpenSpoiler",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"fieldPath": "tabPreferences",
|
||||
"columnName": "tabPreferences",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsFilter",
|
||||
"columnName": "notificationsFilter",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "oauthScopes",
|
||||
"columnName": "oauthScopes",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "unifiedPushUrl",
|
||||
"columnName": "unifiedPushUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushPubKey",
|
||||
"columnName": "pushPubKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushPrivKey",
|
||||
"columnName": "pushPrivKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushAuth",
|
||||
"columnName": "pushAuth",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushServerKey",
|
||||
"columnName": "pushServerKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_AccountEntity_domain_accountId",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"domain",
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `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, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, 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
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxPollOptions",
|
||||
"columnName": "maxPollOptions",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxPollOptionLength",
|
||||
"columnName": "maxPollOptionLength",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "minPollDuration",
|
||||
"columnName": "minPollDuration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxPollDuration",
|
||||
"columnName": "maxPollDuration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "charactersReservedPerUrl",
|
||||
"columnName": "charactersReservedPerUrl",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "version",
|
||||
"columnName": "version",
|
||||
"affinity": "TEXT",
|
||||
"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, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` 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": "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": "repliesCount",
|
||||
"columnName": "repliesCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "reblogged",
|
||||
"columnName": "reblogged",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookmarked",
|
||||
"columnName": "bookmarked",
|
||||
"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": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "visibility",
|
||||
"columnName": "visibility",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mentions",
|
||||
"columnName": "mentions",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"fieldPath": "poll",
|
||||
"columnName": "poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "muted",
|
||||
"columnName": "muted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "expanded",
|
||||
"columnName": "expanded",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentCollapsed",
|
||||
"columnName": "contentCollapsed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentShowing",
|
||||
"columnName": "contentShowing",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pinned",
|
||||
"columnName": "pinned",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "card",
|
||||
"columnName": "card",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"serverId",
|
||||
"timelineUserId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"authorServerId",
|
||||
"timelineUserId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `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, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "serverId",
|
||||
"columnName": "serverId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timelineUserId",
|
||||
"columnName": "timelineUserId",
|
||||
"affinity": "INTEGER",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"fieldPath": "bot",
|
||||
"columnName": "bot",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"serverId",
|
||||
"timelineUserId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "ConversationEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "order",
|
||||
"columnName": "order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accounts",
|
||||
"columnName": "accounts",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "unread",
|
||||
"columnName": "unread",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.id",
|
||||
"columnName": "s_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.url",
|
||||
"columnName": "s_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.inReplyToId",
|
||||
"columnName": "s_inReplyToId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.inReplyToAccountId",
|
||||
"columnName": "s_inReplyToAccountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.account",
|
||||
"columnName": "s_account",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.content",
|
||||
"columnName": "s_content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.createdAt",
|
||||
"columnName": "s_createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.emojis",
|
||||
"columnName": "s_emojis",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.favouritesCount",
|
||||
"columnName": "s_favouritesCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.repliesCount",
|
||||
"columnName": "s_repliesCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.favourited",
|
||||
"columnName": "s_favourited",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.bookmarked",
|
||||
"columnName": "s_bookmarked",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.sensitive",
|
||||
"columnName": "s_sensitive",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.spoilerText",
|
||||
"columnName": "s_spoilerText",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.attachments",
|
||||
"columnName": "s_attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.mentions",
|
||||
"columnName": "s_mentions",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.tags",
|
||||
"columnName": "s_tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.showingHiddenContent",
|
||||
"columnName": "s_showingHiddenContent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.expanded",
|
||||
"columnName": "s_expanded",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.collapsed",
|
||||
"columnName": "s_collapsed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.muted",
|
||||
"columnName": "s_muted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.poll",
|
||||
"columnName": "s_poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"accountId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"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, 'ed3b752a3faec9d092d5ac0a2823d5d5')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -14,6 +14,7 @@
|
|||
|
||||
<application
|
||||
android:name=".TuskyApplication"
|
||||
android:appCategory="social"
|
||||
android:allowBackup="false"
|
||||
android:icon="@drawable/ic_chinwag_logo_simple"
|
||||
android:label="@string/app_name"
|
||||
|
@ -146,6 +147,29 @@
|
|||
android:name=".receiver.SendStatusBroadcastReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
<receiver
|
||||
android:exported="true"
|
||||
android:enabled="true"
|
||||
android:name=".receiver.UnifiedPushBroadcastReceiver"
|
||||
tools:ignore="ExportedReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
|
||||
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
|
||||
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
|
||||
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
|
||||
<action android:name="org.unifiedpush.android.connector.REGISTRATION_REFUSED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver
|
||||
android:exported="true"
|
||||
android:enabled="true"
|
||||
android:name=".receiver.NotificationBlockStateBroadcastReceiver"
|
||||
tools:ignore="ExportedReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="android.app.action.NOTIFICATION_CHANNEL_BLOCK_STATE_CHANGED"/>
|
||||
<action android:name="android.app.action.NOTIFICATION_CHANNEL_GROUP_BLOCK_STATE_CHANGED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name=".service.TuskyTileService"
|
||||
|
|
|
@ -24,12 +24,11 @@ import android.widget.LinearLayout
|
|||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
|
||||
import autodispose2.autoDispose
|
||||
import com.keylesspalace.tusky.databinding.FragmentAccountsInListBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
|
@ -45,7 +44,7 @@ import com.keylesspalace.tusky.util.show
|
|||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.State
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -98,10 +97,8 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
|||
binding.accountsSearchRecycler.layoutManager = LinearLayoutManager(view.context)
|
||||
binding.accountsSearchRecycler.adapter = searchAdapter
|
||||
|
||||
viewModel.state
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(from(this))
|
||||
.subscribe { state ->
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.state.collect { state ->
|
||||
adapter.submitList(state.accounts.asRightOrNull() ?: listOf())
|
||||
|
||||
when (state.accounts) {
|
||||
|
@ -111,6 +108,7 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
|||
|
||||
setupSearchView(state)
|
||||
}
|
||||
}
|
||||
|
||||
binding.searchView.isSubmitButtonEnabled = true
|
||||
binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
|
|
|
@ -31,14 +31,13 @@ import android.widget.TextView
|
|||
import androidx.activity.viewModels
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
|
||||
import autodispose2.autoDispose
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.databinding.ActivityListsBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
|
@ -63,7 +62,7 @@ import com.mikepenz.iconics.utils.colorInt
|
|||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
|
@ -102,19 +101,18 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
|
||||
)
|
||||
|
||||
viewModel.state
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(from(this))
|
||||
.subscribe(this::update)
|
||||
lifecycleScope.launch {
|
||||
viewModel.state.collect(this@ListsActivity::update)
|
||||
}
|
||||
|
||||
viewModel.retryLoading()
|
||||
|
||||
binding.addListButton.setOnClickListener {
|
||||
showlistNameDialog(null)
|
||||
}
|
||||
|
||||
viewModel.events.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(from(this))
|
||||
.subscribe { event ->
|
||||
lifecycleScope.launch {
|
||||
viewModel.events.collect { event ->
|
||||
@Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA")
|
||||
when (event) {
|
||||
Event.CREATE_ERROR -> showMessage(R.string.error_create_list)
|
||||
|
@ -122,6 +120,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
Event.DELETE_ERROR -> showMessage(R.string.error_delete_list)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showlistNameDialog(list: MastoList?) {
|
||||
|
@ -198,9 +197,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
).show()
|
||||
}
|
||||
|
||||
private fun onListSelected(listId: String) {
|
||||
private fun onListSelected(listId: String, listTitle: String) {
|
||||
startActivityWithSlideInAnimation(
|
||||
StatusListActivity.newListIntent(this, listId)
|
||||
StatusListActivity.newListIntent(this, listId, listTitle)
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -270,7 +269,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
|
||||
override fun onClick(v: View) {
|
||||
if (v == itemView) {
|
||||
onListSelected(getItem(bindingAdapterPosition).id)
|
||||
val list = getItem(bindingAdapterPosition)
|
||||
onListSelected(list.id, list.title)
|
||||
} else {
|
||||
onMore(getItem(bindingAdapterPosition), v)
|
||||
}
|
||||
|
|
|
@ -40,6 +40,7 @@ import androidx.lifecycle.Lifecycle
|
|||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.viewpager2.widget.MarginPageTransformer
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import autodispose2.androidx.lifecycle.autoDispose
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.RequestManager
|
||||
|
@ -60,11 +61,12 @@ import com.keylesspalace.tusky.components.account.AccountActivity
|
|||
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType
|
||||
import com.keylesspalace.tusky.components.conversation.ConversationsRepository
|
||||
import com.keylesspalace.tusky.components.drafts.DraftHelper
|
||||
import com.keylesspalace.tusky.components.drafts.DraftsActivity
|
||||
import com.keylesspalace.tusky.components.login.LoginActivity
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.components.notifications.disableAllNotifications
|
||||
import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback
|
||||
import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary
|
||||
import com.keylesspalace.tusky.components.preference.PreferencesActivity
|
||||
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
|
||||
import com.keylesspalace.tusky.components.search.SearchActivity
|
||||
|
@ -76,11 +78,12 @@ import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
|||
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
||||
import com.keylesspalace.tusky.pager.MainPagerAdapter
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.usecase.LogoutUsecase
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.deleteStaleCachedMedia
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.removeShortcut
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.updateShortcut
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
|
@ -130,10 +133,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
lateinit var cacheUpdater: CacheUpdater
|
||||
|
||||
@Inject
|
||||
lateinit var conversationRepository: ConversationsRepository
|
||||
|
||||
@Inject
|
||||
lateinit var draftHelper: DraftHelper
|
||||
lateinit var logoutUsecase: LogoutUsecase
|
||||
|
||||
private val binding by viewBinding(ActivityMainBinding::inflate)
|
||||
|
||||
|
@ -242,12 +242,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
|
||||
setupTabs(showNotificationTab)
|
||||
|
||||
// Setup push notifications
|
||||
if (NotificationHelper.areNotificationsEnabled(this, accountManager)) {
|
||||
NotificationHelper.enablePullNotifications(this)
|
||||
} else {
|
||||
NotificationHelper.disablePullNotifications(this)
|
||||
}
|
||||
eventHub.events
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
|
||||
|
@ -636,7 +630,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
// open LoginActivity to add new account
|
||||
if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) {
|
||||
startActivityWithSlideInAnimation(LoginActivity.getIntent(this, true))
|
||||
startActivityWithSlideInAnimation(LoginActivity.getIntent(this, LoginActivity.MODE_ADDITIONAL_LOGIN))
|
||||
return false
|
||||
}
|
||||
// change Account
|
||||
|
@ -665,24 +659,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
.setTitle(R.string.action_logout)
|
||||
.setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName))
|
||||
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
|
||||
binding.appBar.hide()
|
||||
binding.viewPager.hide()
|
||||
binding.progressBar.show()
|
||||
binding.bottomNav.hide()
|
||||
binding.composeButton.hide()
|
||||
|
||||
lifecycleScope.launch {
|
||||
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this@MainActivity)
|
||||
cacheUpdater.clearForUser(activeAccount.id)
|
||||
conversationRepository.deleteCacheForAccount(activeAccount.id)
|
||||
draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id)
|
||||
removeShortcut(this@MainActivity, activeAccount)
|
||||
val newAccount = accountManager.logActiveAccountOut()
|
||||
if (!NotificationHelper.areNotificationsEnabled(
|
||||
this@MainActivity,
|
||||
accountManager
|
||||
)
|
||||
) {
|
||||
NotificationHelper.disablePullNotifications(this@MainActivity)
|
||||
}
|
||||
val intent = if (newAccount == null) {
|
||||
LoginActivity.getIntent(this@MainActivity, false)
|
||||
} else {
|
||||
val otherAccountAvailable = logoutUsecase.logout()
|
||||
val intent = if (otherAccountAvailable) {
|
||||
Intent(this@MainActivity, MainActivity::class.java)
|
||||
} else {
|
||||
LoginActivity.getIntent(this@MainActivity, LoginActivity.MODE_DEFAULT)
|
||||
}
|
||||
startActivity(intent)
|
||||
finishWithoutSlideOutAnimation()
|
||||
|
@ -714,6 +702,16 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
accountManager.updateActiveAccount(me)
|
||||
NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this)
|
||||
|
||||
// Setup push notifications
|
||||
showMigrationNoticeIfNecessary(this, binding.mainCoordinatorLayout, binding.composeButton, accountManager)
|
||||
if (NotificationHelper.areNotificationsEnabled(this, accountManager)) {
|
||||
lifecycleScope.launch {
|
||||
enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager)
|
||||
}
|
||||
} else {
|
||||
disableAllNotifications(this, accountManager)
|
||||
}
|
||||
|
||||
accountLocked = me.locked
|
||||
|
||||
updateProfiles()
|
||||
|
|
|
@ -20,7 +20,6 @@ import android.content.Intent
|
|||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.keylesspalace.tusky.components.login.LoginActivity
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import javax.inject.Inject
|
||||
|
@ -34,16 +33,12 @@ class SplashActivity : AppCompatActivity(), Injectable {
|
|||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
/** delete old notification channels */
|
||||
NotificationHelper.deleteLegacyNotificationChannels(this, accountManager)
|
||||
|
||||
/** Determine whether the user is currently logged in, and if so go ahead and load the
|
||||
* timeline. Otherwise, start the activity_login screen. */
|
||||
|
||||
val intent = if (accountManager.activeAccount != null) {
|
||||
Intent(this, MainActivity::class.java)
|
||||
} else {
|
||||
LoginActivity.getIntent(this, false)
|
||||
LoginActivity.getIntent(this, LoginActivity.MODE_DEFAULT)
|
||||
}
|
||||
startActivity(intent)
|
||||
finish()
|
||||
|
|
|
@ -46,7 +46,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
Kind.FAVOURITES -> getString(R.string.title_favourites)
|
||||
Kind.BOOKMARKS -> getString(R.string.title_bookmarks)
|
||||
Kind.TAG -> getString(R.string.title_tag).format(hashtag)
|
||||
else -> getString(R.string.title_list_timeline)
|
||||
else -> intent.getStringExtra(EXTRA_LIST_TITLE)
|
||||
}
|
||||
|
||||
supportActionBar?.run {
|
||||
|
@ -73,6 +73,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
|
||||
private const val EXTRA_KIND = "kind"
|
||||
private const val EXTRA_LIST_ID = "id"
|
||||
private const val EXTRA_LIST_TITLE = "title"
|
||||
private const val EXTRA_HASHTAG = "tag"
|
||||
|
||||
fun newFavouritesIntent(context: Context) =
|
||||
|
@ -85,10 +86,11 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
putExtra(EXTRA_KIND, Kind.BOOKMARKS.name)
|
||||
}
|
||||
|
||||
fun newListIntent(context: Context, listId: String) =
|
||||
fun newListIntent(context: Context, listId: String, listTitle: String) =
|
||||
Intent(context, StatusListActivity::class.java).apply {
|
||||
putExtra(EXTRA_KIND, Kind.LIST.name)
|
||||
putExtra(EXTRA_LIST_ID, listId)
|
||||
putExtra(EXTRA_LIST_TITLE, listTitle)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
|
|
|
@ -25,11 +25,13 @@ import androidx.appcompat.widget.AppCompatEditText
|
|||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.transition.TransitionManager
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
|
||||
import autodispose2.autoDispose
|
||||
|
@ -46,9 +48,9 @@ import com.keylesspalace.tusky.network.MastodonApi
|
|||
import com.keylesspalace.tusky.util.onTextChanged
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.regex.Pattern
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -253,10 +255,8 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
|
||||
private fun showSelectListDialog() {
|
||||
val adapter = ListSelectionAdapter(this)
|
||||
mastodonApi.getLists()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
|
||||
.subscribe(
|
||||
lifecycleScope.launch {
|
||||
mastodonApi.getLists().fold(
|
||||
{ lists ->
|
||||
adapter.addAll(lists)
|
||||
},
|
||||
|
@ -264,6 +264,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
Log.e("TabPreferenceActivity", "failed to load lists", throwable)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.select_list_title)
|
||||
|
|
|
@ -44,6 +44,7 @@ class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(co
|
|||
|
||||
binding.username.text = account.fullName
|
||||
binding.displayName.text = account.displayName.emojify(account.emojis, binding.displayName, animateEmojis)
|
||||
binding.avatarBadge.visibility = View.GONE // We never want to display the bot badge here
|
||||
|
||||
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp)
|
||||
val animateAvatar = pm.getBoolean("animateGifAvatars", false)
|
||||
|
|
|
@ -1,42 +0,0 @@
|
|||
/* Copyright 2019 Conny Duck
|
||||
*
|
||||
* 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.adapter
|
||||
|
||||
import androidx.paging.LoadState
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
|
||||
class NetworkStateViewHolder(
|
||||
private val binding: ItemNetworkStateBinding,
|
||||
private val retryCallback: () -> Unit
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun setUpWithNetworkState(state: LoadState) {
|
||||
binding.progressBar.visible(state == LoadState.Loading)
|
||||
binding.retryButton.visible(state is LoadState.Error)
|
||||
val msg = if (state is LoadState.Error) {
|
||||
state.error.message
|
||||
} else {
|
||||
null
|
||||
}
|
||||
binding.errorMsg.visible(msg != null)
|
||||
binding.errorMsg.text = msg
|
||||
binding.retryButton.setOnClickListener {
|
||||
retryCallback()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -71,10 +71,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
public static class Key {
|
||||
public static final String KEY_CREATED = "created";
|
||||
}
|
||||
|
||||
private TextView displayName;
|
||||
private TextView username;
|
||||
private ImageButton replyButton;
|
||||
private TextView replyCountLabel;
|
||||
private SparkButton reblogButton;
|
||||
private SparkButton favouriteButton;
|
||||
private SparkButton bookmarkButton;
|
||||
|
@ -123,6 +123,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
content = itemView.findViewById(R.id.status_content);
|
||||
avatar = itemView.findViewById(R.id.status_avatar);
|
||||
replyButton = itemView.findViewById(R.id.status_reply);
|
||||
replyCountLabel = itemView.findViewById(R.id.status_replies);
|
||||
reblogButton = itemView.findViewById(R.id.status_inset);
|
||||
favouriteButton = itemView.findViewById(R.id.status_favourite);
|
||||
bookmarkButton = itemView.findViewById(R.id.status_bookmark);
|
||||
|
@ -360,6 +361,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
}
|
||||
|
||||
private void setReplyCount(int repliesCount) {
|
||||
// This label only exists in the non-detailed view (to match the web ui)
|
||||
if (replyCountLabel != null) {
|
||||
replyCountLabel.setText((repliesCount > 1 ? replyCountLabel.getContext().getString(R.string.status_count_one_plus) : Integer.toString(repliesCount)));
|
||||
}
|
||||
}
|
||||
|
||||
private void setReblogged(boolean reblogged) {
|
||||
reblogButton.setChecked(reblogged);
|
||||
}
|
||||
|
@ -733,6 +741,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
setUsername(status.getUsername());
|
||||
setCreatedAt(actionable.getCreatedAt(), statusDisplayOptions);
|
||||
setIsReply(actionable.getInReplyToId() != null);
|
||||
setReplyCount(actionable.getRepliesCount());
|
||||
setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
|
||||
actionable.getAccount().getBot(), statusDisplayOptions);
|
||||
setReblogged(actionable.getReblogged());
|
||||
|
@ -1037,6 +1046,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
actionable.getPoll() == null &&
|
||||
card != null &&
|
||||
!TextUtils.isEmpty(card.getUrl()) &&
|
||||
(!actionable.getSensitive() || status.isExpanded()) &&
|
||||
(!status.isCollapsible() || !status.isCollapsed())) {
|
||||
cardView.setVisibility(View.VISIBLE);
|
||||
cardTitle.setText(card.getTitle());
|
||||
|
|
|
@ -103,20 +103,26 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
@NonNull final StatusActionListener listener,
|
||||
@NonNull StatusDisplayOptions statusDisplayOptions,
|
||||
@Nullable Object payloads) {
|
||||
super.setupWithStatus(status, listener, statusDisplayOptions, payloads);
|
||||
setupCard(status, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
|
||||
// We never collapse statuses in the detail view
|
||||
StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ?
|
||||
status.copyWithCollapsed(false) :
|
||||
status;
|
||||
|
||||
super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads);
|
||||
setupCard(uncollapsedStatus, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
|
||||
if (payloads == null) {
|
||||
Status actionable = uncollapsedStatus.getActionable();
|
||||
|
||||
if (!statusDisplayOptions.hideStats()) {
|
||||
setReblogAndFavCount(status.getActionable().getReblogsCount(),
|
||||
status.getActionable().getFavouritesCount(), listener);
|
||||
setReblogAndFavCount(actionable.getReblogsCount(),
|
||||
actionable.getFavouritesCount(), listener);
|
||||
} else {
|
||||
hideQuantitativeStats();
|
||||
}
|
||||
|
||||
setApplication(status.getActionable().getApplication());
|
||||
setApplication(actionable.getApplication());
|
||||
|
||||
setStatusVisibility(status.getActionable().getVisibility());
|
||||
setStatusVisibility(actionable.getVisibility());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import javax.inject.Inject
|
|||
class CacheUpdater @Inject constructor(
|
||||
eventHub: EventHub,
|
||||
private val accountManager: AccountManager,
|
||||
private val appDatabase: AppDatabase,
|
||||
appDatabase: AppDatabase,
|
||||
gson: Gson
|
||||
) {
|
||||
|
||||
|
@ -44,8 +44,4 @@ class CacheUpdater @Inject constructor(
|
|||
fun stop() {
|
||||
this.disposable.dispose()
|
||||
}
|
||||
|
||||
suspend fun clearForUser(accountId: Long) {
|
||||
appDatabase.timelineDao().removeAll(accountId)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -84,6 +84,9 @@ import com.keylesspalace.tusky.view.showMuteAccountDialog
|
|||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import java.text.NumberFormat
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.abs
|
||||
|
||||
|
@ -413,6 +416,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
updateToolbar()
|
||||
updateMovedAccount()
|
||||
updateRemoteAccount()
|
||||
updateAccountJoinedDate()
|
||||
updateAccountStats()
|
||||
invalidateOptionsMenu()
|
||||
|
||||
|
@ -422,6 +426,20 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateAccountJoinedDate() {
|
||||
loadedAccount?.let { account ->
|
||||
try {
|
||||
binding.accountDateJoined.text = resources.getString(
|
||||
R.string.account_date_joined,
|
||||
SimpleDateFormat("MMMM, yyyy", Locale.getDefault()).format(account.createdAt)
|
||||
)
|
||||
binding.accountDateJoined.visibility = View.VISIBLE
|
||||
} catch (e: ParseException) {
|
||||
binding.accountDateJoined.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load account's avatar and header image
|
||||
*/
|
||||
|
|
|
@ -20,6 +20,7 @@ import androidx.lifecycle.LiveData
|
|||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||
|
|
|
@ -23,6 +23,7 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.net.Uri
|
||||
|
@ -56,6 +57,9 @@ import androidx.lifecycle.lifecycleScope
|
|||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.transition.TransitionManager
|
||||
import com.canhub.cropper.CropImage
|
||||
import com.canhub.cropper.CropImageContract
|
||||
import com.canhub.cropper.options
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
|
@ -78,12 +82,12 @@ import com.keylesspalace.tusky.entity.Emoji
|
|||
import com.keylesspalace.tusky.entity.NewPoll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.ComposeTokenizer
|
||||
import com.keylesspalace.tusky.util.PickMediaFiles
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.afterTextChanged
|
||||
import com.keylesspalace.tusky.util.combineLiveData
|
||||
import com.keylesspalace.tusky.util.combineOptionalLiveData
|
||||
import com.keylesspalace.tusky.util.getMediaSize
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.highlightSpans
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
|
@ -152,6 +156,32 @@ class ComposeActivity :
|
|||
}
|
||||
}
|
||||
|
||||
// Contract kicked off by editImageInQueue; expects viewModel.cropImageItemOld set
|
||||
private val cropImage = registerForActivityResult(CropImageContract()) { result ->
|
||||
val uriNew = result.uriContent
|
||||
if (result.isSuccessful && uriNew != null) {
|
||||
viewModel.cropImageItemOld?.let { itemOld ->
|
||||
val size = getMediaSize(contentResolver, uriNew)
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.addMediaToQueue(
|
||||
itemOld.type,
|
||||
uriNew,
|
||||
size,
|
||||
itemOld.description,
|
||||
itemOld
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (result == CropImage.CancelledResult) {
|
||||
Log.w("ComposeActivity", "Edit image cancelled by user")
|
||||
} else {
|
||||
Log.w("ComposeActivity", "Edit image failed: " + result.error)
|
||||
displayTransientError(R.string.error_image_edit_failed)
|
||||
}
|
||||
viewModel.cropImageItemOld = null
|
||||
}
|
||||
|
||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
@ -186,6 +216,7 @@ class ComposeActivity :
|
|||
viewModel.updateDescription(item.localId, newDescription)
|
||||
}
|
||||
},
|
||||
onEditImage = this::editImageInQueue,
|
||||
onRemove = this::removeMediaFromQueue
|
||||
)
|
||||
binding.composeMediaPreviewBar.layoutManager =
|
||||
|
@ -307,7 +338,8 @@ class ComposeActivity :
|
|||
ComposeAutoCompleteAdapter(
|
||||
this,
|
||||
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
|
||||
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
||||
preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
|
||||
)
|
||||
)
|
||||
binding.composeEditField.setTokenizer(ComposeTokenizer())
|
||||
|
@ -375,8 +407,13 @@ class ComposeActivity :
|
|||
enableButton(binding.composeAddMediaButton, active, active)
|
||||
enablePollButton(media.isNullOrEmpty())
|
||||
}.subscribe()
|
||||
viewModel.uploadError.observe {
|
||||
displayTransientError(R.string.error_media_upload_sending)
|
||||
viewModel.uploadError.observe { throwable ->
|
||||
Log.w(TAG, "media upload failed", throwable)
|
||||
if (throwable is UploadServerError) {
|
||||
displayTransientError(throwable.errorMessage)
|
||||
} else {
|
||||
displayTransientError(R.string.error_media_upload_sending)
|
||||
}
|
||||
}
|
||||
viewModel.setupComplete.observe {
|
||||
// Focus may have changed during view model setup, ensure initial focus is on the edit field
|
||||
|
@ -521,19 +558,23 @@ class ComposeActivity :
|
|||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
private fun displayTransientError(@StringRes stringId: Int) {
|
||||
val bar = Snackbar.make(binding.activityCompose, stringId, Snackbar.LENGTH_LONG)
|
||||
private fun displayTransientError(errorMessage: String) {
|
||||
val bar = Snackbar.make(binding.activityCompose, errorMessage, Snackbar.LENGTH_LONG)
|
||||
// necessary so snackbar is shown over everything
|
||||
bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
|
||||
bar.setAnchorView(R.id.composeBottomBar)
|
||||
bar.show()
|
||||
}
|
||||
private fun displayTransientError(@StringRes stringId: Int) {
|
||||
displayTransientError(getString(stringId))
|
||||
}
|
||||
|
||||
private fun toggleHideMedia() {
|
||||
this.viewModel.toggleMarkSensitive()
|
||||
}
|
||||
|
||||
private fun updateSensitiveMediaToggle(markMediaSensitive: Boolean, contentWarningShown: Boolean) {
|
||||
if (viewModel.media.value.isNullOrEmpty()) {
|
||||
if (viewModel.media.value.isEmpty()) {
|
||||
binding.composeHideMediaButton.hide()
|
||||
} else {
|
||||
binding.composeHideMediaButton.show()
|
||||
|
@ -867,6 +908,26 @@ class ComposeActivity :
|
|||
binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
|
||||
private fun editImageInQueue(item: QueuedMedia) {
|
||||
// If input image is lossless, output image should be lossless.
|
||||
// Currently the only supported lossless format is png.
|
||||
val mimeType: String? = contentResolver.getType(item.uri)
|
||||
val isPng: Boolean = mimeType != null && mimeType.endsWith("/png")
|
||||
val tempFile = createNewImageFile(this, if (isPng) ".png" else ".jpg")
|
||||
|
||||
// "Authority" must be the same as the android:authorities string in AndroidManifest.xml
|
||||
val uriNew = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID + ".fileprovider", tempFile)
|
||||
|
||||
viewModel.cropImageItemOld = item
|
||||
|
||||
cropImage.launch(
|
||||
options(uri = item.uri) {
|
||||
setOutputUri(uriNew)
|
||||
setOutputCompressFormat(if (isPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun removeMediaFromQueue(item: QueuedMedia) {
|
||||
viewModel.removeMediaFromQueue(item)
|
||||
}
|
||||
|
|
|
@ -1,320 +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.components.compose;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.BaseAdapter;
|
||||
import android.widget.Filter;
|
||||
import android.widget.Filterable;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.entity.Emoji;
|
||||
import com.keylesspalace.tusky.entity.HashTag;
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount;
|
||||
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
||||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Created by charlag on 12/11/17.
|
||||
*/
|
||||
|
||||
public class ComposeAutoCompleteAdapter extends BaseAdapter
|
||||
implements Filterable {
|
||||
private static final int ACCOUNT_VIEW_TYPE = 1;
|
||||
private static final int HASHTAG_VIEW_TYPE = 2;
|
||||
private static final int EMOJI_VIEW_TYPE = 3;
|
||||
private static final int SEPARATOR_VIEW_TYPE = 0;
|
||||
|
||||
private final ArrayList<AutocompleteResult> resultList;
|
||||
private final AutocompletionProvider autocompletionProvider;
|
||||
private final boolean animateAvatar;
|
||||
private final boolean animateEmojis;
|
||||
|
||||
public ComposeAutoCompleteAdapter(AutocompletionProvider autocompletionProvider, boolean animateAvatar, boolean animateEmojis) {
|
||||
super();
|
||||
resultList = new ArrayList<>();
|
||||
this.autocompletionProvider = autocompletionProvider;
|
||||
this.animateAvatar = animateAvatar;
|
||||
this.animateEmojis = animateEmojis;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return resultList.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AutocompleteResult getItem(int index) {
|
||||
return resultList.get(index);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position) {
|
||||
return position;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public Filter getFilter() {
|
||||
return new Filter() {
|
||||
@Override
|
||||
public CharSequence convertResultToString(Object resultValue) {
|
||||
if (resultValue instanceof AccountResult) {
|
||||
return formatUsername(((AccountResult) resultValue));
|
||||
} else if (resultValue instanceof HashtagResult) {
|
||||
return formatHashtag((HashtagResult) resultValue);
|
||||
} else if (resultValue instanceof EmojiResult) {
|
||||
return formatEmoji((EmojiResult) resultValue);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// This method is invoked in a worker thread.
|
||||
@Override
|
||||
protected FilterResults performFiltering(CharSequence constraint) {
|
||||
FilterResults filterResults = new FilterResults();
|
||||
if (constraint != null) {
|
||||
List<AutocompleteResult> results =
|
||||
autocompletionProvider.search(constraint.toString());
|
||||
filterResults.values = results;
|
||||
filterResults.count = results.size();
|
||||
}
|
||||
return filterResults;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
protected void publishResults(CharSequence constraint, FilterResults results) {
|
||||
if (results != null && results.count > 0) {
|
||||
resultList.clear();
|
||||
resultList.addAll((List<AutocompleteResult>) results.values);
|
||||
notifyDataSetChanged();
|
||||
} else {
|
||||
notifyDataSetInvalidated();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
|
||||
View view = convertView;
|
||||
final Context context = parent.getContext();
|
||||
|
||||
switch (getItemViewType(position)) {
|
||||
case ACCOUNT_VIEW_TYPE:
|
||||
AccountViewHolder accountViewHolder;
|
||||
if (convertView == null) {
|
||||
view = ((LayoutInflater) context
|
||||
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
|
||||
.inflate(R.layout.item_autocomplete_account, parent, false);
|
||||
}
|
||||
if (view.getTag() == null) {
|
||||
view.setTag(new AccountViewHolder(view));
|
||||
}
|
||||
accountViewHolder = (AccountViewHolder) view.getTag();
|
||||
|
||||
AccountResult accountResult = ((AccountResult) getItem(position));
|
||||
if (accountResult != null) {
|
||||
TimelineAccount account = accountResult.account;
|
||||
String formattedUsername = context.getString(
|
||||
R.string.post_username_format,
|
||||
account.getUsername()
|
||||
);
|
||||
accountViewHolder.username.setText(formattedUsername);
|
||||
CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(),
|
||||
account.getEmojis(), accountViewHolder.displayName, animateEmojis);
|
||||
accountViewHolder.displayName.setText(emojifiedName);
|
||||
|
||||
int avatarRadius = accountViewHolder.avatar.getContext().getResources()
|
||||
.getDimensionPixelSize(R.dimen.avatar_radius_42dp);
|
||||
|
||||
ImageLoadingHelper.loadAvatar(
|
||||
account.getAvatar(),
|
||||
accountViewHolder.avatar,
|
||||
avatarRadius,
|
||||
animateAvatar
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case HASHTAG_VIEW_TYPE:
|
||||
if (convertView == null) {
|
||||
view = ((LayoutInflater) context
|
||||
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
|
||||
.inflate(R.layout.item_autocomplete_hashtag, parent, false);
|
||||
}
|
||||
|
||||
HashtagResult result = (HashtagResult) getItem(position);
|
||||
if (result != null) {
|
||||
((TextView) view).setText(formatHashtag(result));
|
||||
}
|
||||
break;
|
||||
|
||||
case EMOJI_VIEW_TYPE:
|
||||
EmojiViewHolder emojiViewHolder;
|
||||
if (convertView == null) {
|
||||
view = ((LayoutInflater) context
|
||||
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
|
||||
.inflate(R.layout.item_autocomplete_emoji, parent, false);
|
||||
}
|
||||
if (view.getTag() == null) {
|
||||
view.setTag(new EmojiViewHolder(view));
|
||||
}
|
||||
emojiViewHolder = (EmojiViewHolder) view.getTag();
|
||||
|
||||
EmojiResult emojiResult = ((EmojiResult) getItem(position));
|
||||
if (emojiResult != null) {
|
||||
Emoji emoji = emojiResult.emoji;
|
||||
String formattedShortcode = context.getString(
|
||||
R.string.emoji_shortcode_format,
|
||||
emoji.getShortcode()
|
||||
);
|
||||
emojiViewHolder.shortcode.setText(formattedShortcode);
|
||||
Glide.with(emojiViewHolder.preview)
|
||||
.load(emoji.getUrl())
|
||||
.into(emojiViewHolder.preview);
|
||||
}
|
||||
break;
|
||||
|
||||
case SEPARATOR_VIEW_TYPE:
|
||||
if (convertView == null) {
|
||||
view = ((LayoutInflater) context
|
||||
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
|
||||
.inflate(R.layout.item_autocomplete_divider, parent, false);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new AssertionError("unknown view type");
|
||||
}
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
private static String formatUsername(AccountResult result) {
|
||||
return String.format("@%s", result.account.getUsername());
|
||||
}
|
||||
|
||||
private static String formatHashtag(HashtagResult result) {
|
||||
return String.format("#%s", result.hashtag);
|
||||
}
|
||||
|
||||
private static String formatEmoji(EmojiResult result) {
|
||||
return String.format(":%s:", result.emoji.getShortcode());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getViewTypeCount() {
|
||||
return 4;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemViewType(int position) {
|
||||
AutocompleteResult item = getItem(position);
|
||||
|
||||
if (item instanceof AccountResult) {
|
||||
return ACCOUNT_VIEW_TYPE;
|
||||
} else if (item instanceof HashtagResult) {
|
||||
return HASHTAG_VIEW_TYPE;
|
||||
} else if (item instanceof EmojiResult) {
|
||||
return EMOJI_VIEW_TYPE;
|
||||
} else {
|
||||
return SEPARATOR_VIEW_TYPE;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areAllItemsEnabled() {
|
||||
// there may be separators
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled(int position) {
|
||||
return !(getItem(position) instanceof ResultSeparator);
|
||||
}
|
||||
|
||||
public abstract static class AutocompleteResult {
|
||||
AutocompleteResult() {
|
||||
}
|
||||
}
|
||||
|
||||
public final static class AccountResult extends AutocompleteResult {
|
||||
private final TimelineAccount account;
|
||||
|
||||
public AccountResult(TimelineAccount account) {
|
||||
this.account = account;
|
||||
}
|
||||
}
|
||||
|
||||
public final static class HashtagResult extends AutocompleteResult {
|
||||
private final String hashtag;
|
||||
|
||||
public HashtagResult(HashTag hashtag) {
|
||||
this.hashtag = hashtag.getName();
|
||||
}
|
||||
}
|
||||
|
||||
public final static class EmojiResult extends AutocompleteResult {
|
||||
private final Emoji emoji;
|
||||
|
||||
public EmojiResult(Emoji emoji) {
|
||||
this.emoji = emoji;
|
||||
}
|
||||
}
|
||||
|
||||
public final static class ResultSeparator extends AutocompleteResult {}
|
||||
|
||||
public interface AutocompletionProvider {
|
||||
List<AutocompleteResult> search(String mention);
|
||||
}
|
||||
|
||||
private class AccountViewHolder {
|
||||
final TextView username;
|
||||
final TextView displayName;
|
||||
final ImageView avatar;
|
||||
|
||||
private AccountViewHolder(View view) {
|
||||
username = view.findViewById(R.id.username);
|
||||
displayName = view.findViewById(R.id.display_name);
|
||||
avatar = view.findViewById(R.id.avatar);
|
||||
}
|
||||
}
|
||||
|
||||
private class EmojiViewHolder {
|
||||
final TextView shortcode;
|
||||
final ImageView preview;
|
||||
|
||||
private EmojiViewHolder(View view) {
|
||||
shortcode = view.findViewById(R.id.shortcode);
|
||||
preview = view.findViewById(R.id.preview);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,175 @@
|
|||
/* Copyright 2022 Tusky Contributors
|
||||
*
|
||||
* 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.components.compose
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.Filter
|
||||
import android.widget.Filterable
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.bumptech.glide.Glide
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemAutocompleteAccountBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemAutocompleteEmojiBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemAutocompleteHashtagBinding
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
|
||||
class ComposeAutoCompleteAdapter(
|
||||
private val autocompletionProvider: AutocompletionProvider,
|
||||
private val animateAvatar: Boolean,
|
||||
private val animateEmojis: Boolean,
|
||||
private val showBotBadge: Boolean
|
||||
) : BaseAdapter(), Filterable {
|
||||
|
||||
private var resultList: List<AutocompleteResult> = emptyList()
|
||||
|
||||
override fun getCount() = resultList.size
|
||||
|
||||
override fun getItem(index: Int): AutocompleteResult {
|
||||
return resultList[index]
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return position.toLong()
|
||||
}
|
||||
|
||||
override fun getFilter(): Filter {
|
||||
return object : Filter() {
|
||||
|
||||
override fun convertResultToString(resultValue: Any): CharSequence {
|
||||
return when (resultValue) {
|
||||
is AutocompleteResult.AccountResult -> formatUsername(resultValue)
|
||||
is AutocompleteResult.HashtagResult -> formatHashtag(resultValue)
|
||||
is AutocompleteResult.EmojiResult -> formatEmoji(resultValue)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun performFiltering(constraint: CharSequence?): FilterResults {
|
||||
val filterResults = FilterResults()
|
||||
if (constraint != null) {
|
||||
val results = autocompletionProvider.search(constraint.toString())
|
||||
filterResults.values = results
|
||||
filterResults.count = results.size
|
||||
}
|
||||
return filterResults
|
||||
}
|
||||
|
||||
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
|
||||
if (results.count > 0) {
|
||||
resultList = results.values as List<AutocompleteResult>
|
||||
notifyDataSetChanged()
|
||||
} else {
|
||||
notifyDataSetInvalidated()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val itemViewType = getItemViewType(position)
|
||||
val context = parent.context
|
||||
|
||||
val view: View = convertView ?: run {
|
||||
val layoutInflater = LayoutInflater.from(context)
|
||||
val binding = when (itemViewType) {
|
||||
ACCOUNT_VIEW_TYPE -> ItemAutocompleteAccountBinding.inflate(layoutInflater)
|
||||
HASHTAG_VIEW_TYPE -> ItemAutocompleteHashtagBinding.inflate(layoutInflater)
|
||||
EMOJI_VIEW_TYPE -> ItemAutocompleteEmojiBinding.inflate(layoutInflater)
|
||||
else -> throw AssertionError("unknown view type")
|
||||
}
|
||||
binding.root.tag = binding
|
||||
binding.root
|
||||
}
|
||||
|
||||
when (val binding = view.tag) {
|
||||
is ItemAutocompleteAccountBinding -> {
|
||||
val accountResult = getItem(position) as AutocompleteResult.AccountResult
|
||||
val account = accountResult.account
|
||||
binding.username.text = context.getString(R.string.post_username_format, account.username)
|
||||
binding.displayName.text = account.name.emojify(account.emojis, binding.displayName, animateEmojis)
|
||||
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp)
|
||||
loadAvatar(
|
||||
account.avatar,
|
||||
binding.avatar,
|
||||
avatarRadius,
|
||||
animateAvatar
|
||||
)
|
||||
binding.avatarBadge.visible(showBotBadge && account.bot)
|
||||
}
|
||||
is ItemAutocompleteHashtagBinding -> {
|
||||
val result = getItem(position) as AutocompleteResult.HashtagResult
|
||||
binding.root.text = formatHashtag(result)
|
||||
}
|
||||
is ItemAutocompleteEmojiBinding -> {
|
||||
val emojiResult = getItem(position) as AutocompleteResult.EmojiResult
|
||||
val (shortcode, url) = emojiResult.emoji
|
||||
binding.shortcode.text = context.getString(R.string.emoji_shortcode_format, shortcode)
|
||||
Glide.with(binding.preview)
|
||||
.load(url)
|
||||
.into(binding.preview)
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
override fun getViewTypeCount() = 3
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return when (getItem(position)) {
|
||||
is AutocompleteResult.AccountResult -> ACCOUNT_VIEW_TYPE
|
||||
is AutocompleteResult.HashtagResult -> HASHTAG_VIEW_TYPE
|
||||
is AutocompleteResult.EmojiResult -> EMOJI_VIEW_TYPE
|
||||
}
|
||||
}
|
||||
|
||||
sealed class AutocompleteResult {
|
||||
class AccountResult(val account: TimelineAccount) : AutocompleteResult()
|
||||
|
||||
class HashtagResult(val hashtag: String) : AutocompleteResult()
|
||||
|
||||
class EmojiResult(val emoji: Emoji) : AutocompleteResult()
|
||||
}
|
||||
|
||||
interface AutocompletionProvider {
|
||||
fun search(token: String): List<AutocompleteResult>
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACCOUNT_VIEW_TYPE = 0
|
||||
private const val HASHTAG_VIEW_TYPE = 1
|
||||
private const val EMOJI_VIEW_TYPE = 2
|
||||
|
||||
private fun formatUsername(result: AutocompleteResult.AccountResult): String {
|
||||
return String.format("@%s", result.account.username)
|
||||
}
|
||||
|
||||
private fun formatHashtag(result: AutocompleteResult.HashtagResult): String {
|
||||
return String.format("#%s", result.hashtag)
|
||||
}
|
||||
|
||||
private fun formatEmoji(result: AutocompleteResult.EmojiResult): String {
|
||||
return String.format(":%s:", result.emoji.shortcode)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@
|
|||
* 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
|
||||
package com.keylesspalace.tusky.components.compose
|
||||
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
|
@ -23,7 +23,9 @@ import androidx.lifecycle.MutableLiveData
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
||||
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult
|
||||
import com.keylesspalace.tusky.components.drafts.DraftHelper
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||
|
@ -51,7 +53,6 @@ import kotlinx.coroutines.flow.updateAndGet
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.rxSingle
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
class ComposeViewModel @Inject constructor(
|
||||
|
@ -94,6 +95,9 @@ class ComposeViewModel @Inject constructor(
|
|||
|
||||
private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty()
|
||||
|
||||
// Used in ComposeActivity to pass state to result function when cropImage contract inflight
|
||||
var cropImageItemOld: QueuedMedia? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
emoji.postValue(instanceInfoRepo.getEmojis())
|
||||
|
@ -121,13 +125,16 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun addMediaToQueue(
|
||||
suspend fun addMediaToQueue(
|
||||
type: QueuedMedia.Type,
|
||||
uri: Uri,
|
||||
mediaSize: Long,
|
||||
description: String? = null
|
||||
description: String? = null,
|
||||
replaceItem: QueuedMedia? = null
|
||||
): QueuedMedia {
|
||||
val mediaItem = media.updateAndGet { mediaValue ->
|
||||
var stashMediaItem: QueuedMedia? = null
|
||||
|
||||
media.updateAndGet { mediaValue ->
|
||||
val mediaItem = QueuedMedia(
|
||||
localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1,
|
||||
uri = uri,
|
||||
|
@ -135,8 +142,19 @@ class ComposeViewModel @Inject constructor(
|
|||
mediaSize = mediaSize,
|
||||
description = description
|
||||
)
|
||||
mediaValue + mediaItem
|
||||
}.last()
|
||||
stashMediaItem = mediaItem
|
||||
|
||||
if (replaceItem != null) {
|
||||
mediaToJob[replaceItem.localId]?.cancel()
|
||||
mediaValue.map {
|
||||
if (it.localId == replaceItem.localId) mediaItem else it
|
||||
}
|
||||
} else { // Append
|
||||
mediaValue + mediaItem
|
||||
}
|
||||
}
|
||||
val mediaItem = stashMediaItem!! // stashMediaItem is always non-null and uncaptured at this point, but Kotlin doesn't know that
|
||||
|
||||
mediaToJob[mediaItem.localId] = viewModelScope.launch {
|
||||
mediaUploader
|
||||
.uploadMedia(mediaItem)
|
||||
|
@ -201,7 +219,7 @@ class ComposeViewModel @Inject constructor(
|
|||
val contentWarningChanged = showContentWarning.value!! &&
|
||||
!contentWarning.isNullOrEmpty() &&
|
||||
!startingContentWarning.startsWith(contentWarning.toString())
|
||||
val mediaChanged = !media.value.isNullOrEmpty()
|
||||
val mediaChanged = media.value.isNotEmpty()
|
||||
val pollChanged = poll.value != null
|
||||
|
||||
return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged
|
||||
|
@ -330,48 +348,37 @@ class ComposeViewModel @Inject constructor(
|
|||
return true
|
||||
}
|
||||
|
||||
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
|
||||
when (token[0]) {
|
||||
'@' -> {
|
||||
return try {
|
||||
api.searchAccounts(query = token.substring(1), limit = 10)
|
||||
.blockingGet()
|
||||
.map { ComposeAutoCompleteAdapter.AccountResult(it) }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
|
||||
emptyList()
|
||||
}
|
||||
return api.searchAccountsSync(query = token.substring(1), limit = 10)
|
||||
.fold({ accounts ->
|
||||
accounts.map { AutocompleteResult.AccountResult(it) }
|
||||
}, { e ->
|
||||
Log.e(TAG, "Autocomplete search for $token failed.", e)
|
||||
emptyList()
|
||||
})
|
||||
}
|
||||
'#' -> {
|
||||
return try {
|
||||
api.searchObservable(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
|
||||
.blockingGet()
|
||||
.hashtags
|
||||
.map { ComposeAutoCompleteAdapter.HashtagResult(it) }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
|
||||
emptyList()
|
||||
}
|
||||
return api.searchSync(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
|
||||
.fold({ searchResult ->
|
||||
searchResult.hashtags.map { AutocompleteResult.HashtagResult(it.name) }
|
||||
}, { e ->
|
||||
Log.e(TAG, "Autocomplete search for $token failed.", e)
|
||||
emptyList()
|
||||
})
|
||||
}
|
||||
':' -> {
|
||||
val emojiList = emoji.value ?: return emptyList()
|
||||
val incomplete = token.substring(1)
|
||||
|
||||
val incomplete = token.substring(1).lowercase(Locale.ROOT)
|
||||
val results = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
|
||||
val resultsInside = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
|
||||
for (emoji in emojiList) {
|
||||
val shortcode = emoji.shortcode.lowercase(Locale.ROOT)
|
||||
if (shortcode.startsWith(incomplete)) {
|
||||
results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
|
||||
} else if (shortcode.indexOf(incomplete, 1) != -1) {
|
||||
resultsInside.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
|
||||
}
|
||||
return emojiList.filter { emoji ->
|
||||
emoji.shortcode.contains(incomplete, ignoreCase = true)
|
||||
}.sortedBy { emoji ->
|
||||
emoji.shortcode.indexOf(incomplete, ignoreCase = true)
|
||||
}.map { emoji ->
|
||||
AutocompleteResult.EmojiResult(emoji)
|
||||
}
|
||||
if (results.isNotEmpty() && resultsInside.isNotEmpty()) {
|
||||
results.add(ComposeAutoCompleteAdapter.ResultSeparator())
|
||||
}
|
||||
results.addAll(resultsInside)
|
||||
return results
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unexpected autocompletion token: $token")
|
||||
|
|
|
@ -32,6 +32,7 @@ import com.keylesspalace.tusky.components.compose.view.ProgressImageView
|
|||
class MediaPreviewAdapter(
|
||||
context: Context,
|
||||
private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit,
|
||||
private val onEditImage: (ComposeActivity.QueuedMedia) -> Unit,
|
||||
private val onRemove: (ComposeActivity.QueuedMedia) -> Unit
|
||||
) : RecyclerView.Adapter<MediaPreviewAdapter.PreviewViewHolder>() {
|
||||
|
||||
|
@ -43,12 +44,16 @@ class MediaPreviewAdapter(
|
|||
val item = differ.currentList[position]
|
||||
val popup = PopupMenu(view.context, view)
|
||||
val addCaptionId = 1
|
||||
val removeId = 2
|
||||
val editImageId = 2
|
||||
val removeId = 3
|
||||
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
|
||||
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE)
|
||||
popup.menu.add(0, editImageId, 0, R.string.action_edit_image)
|
||||
popup.menu.add(0, removeId, 0, R.string.action_remove)
|
||||
popup.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
addCaptionId -> onAddCaption(item)
|
||||
editImageId -> onEditImage(item)
|
||||
removeId -> onRemove(item)
|
||||
}
|
||||
true
|
||||
|
|
|
@ -23,6 +23,7 @@ import android.util.Log
|
|||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toUri
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.BuildConfig
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
||||
|
@ -31,6 +32,7 @@ import com.keylesspalace.tusky.network.ProgressRequestBody
|
|||
import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN
|
||||
import com.keylesspalace.tusky.util.getImageSquarePixels
|
||||
import com.keylesspalace.tusky.util.getMediaSize
|
||||
import com.keylesspalace.tusky.util.getServerErrorMessage
|
||||
import com.keylesspalace.tusky.util.randomAlphanumericString
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
@ -54,14 +56,14 @@ sealed class UploadEvent {
|
|||
data class FinishedEvent(val mediaId: String) : UploadEvent()
|
||||
}
|
||||
|
||||
fun createNewImageFile(context: Context): File {
|
||||
fun createNewImageFile(context: Context, suffix: String = ".jpg"): File {
|
||||
// Create an image file name
|
||||
val randomId = randomAlphanumericString(12)
|
||||
val imageFileName = "Tusky_${randomId}_"
|
||||
val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
|
||||
return File.createTempFile(
|
||||
imageFileName, /* prefix */
|
||||
".jpg", /* suffix */
|
||||
suffix, /* suffix */
|
||||
storageDir /* directory */
|
||||
)
|
||||
}
|
||||
|
@ -72,6 +74,7 @@ class AudioSizeException : Exception()
|
|||
class VideoSizeException : Exception()
|
||||
class MediaTypeException : Exception()
|
||||
class CouldNotOpenFileException : Exception()
|
||||
class UploadServerError(val errorMessage: String) : Exception()
|
||||
|
||||
class MediaUploader @Inject constructor(
|
||||
private val context: Context,
|
||||
|
@ -222,8 +225,16 @@ class MediaUploader @Inject constructor(
|
|||
null
|
||||
}
|
||||
|
||||
val result = mediaUploadApi.uploadMedia(body, description).getOrThrow()
|
||||
send(UploadEvent.FinishedEvent(result.id))
|
||||
mediaUploadApi.uploadMedia(body, description).fold({ result ->
|
||||
send(UploadEvent.FinishedEvent(result.id))
|
||||
}, { throwable ->
|
||||
val errorMessage = throwable.getServerErrorMessage()
|
||||
if (errorMessage == null) {
|
||||
throw throwable
|
||||
} else {
|
||||
throw UploadServerError(errorMessage)
|
||||
}
|
||||
})
|
||||
awaitClose()
|
||||
}
|
||||
}
|
||||
|
@ -240,7 +251,7 @@ class MediaUploader @Inject constructor(
|
|||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "MediaUploaderImpl"
|
||||
private const val TAG = "MediaUploader"
|
||||
private const val STATUS_VIDEO_SIZE_LIMIT = 41943040 // 40MiB
|
||||
private const val STATUS_AUDIO_SIZE_LIMIT = 41943040 // 40MiB
|
||||
private const val STATUS_IMAGE_SIZE_LIMIT = 8388608 // 8MiB
|
||||
|
|
|
@ -20,21 +20,40 @@ import android.view.ViewGroup
|
|||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
|
||||
class ConversationAdapter(
|
||||
private val statusDisplayOptions: StatusDisplayOptions,
|
||||
private var statusDisplayOptions: StatusDisplayOptions,
|
||||
private val listener: StatusActionListener
|
||||
) : PagingDataAdapter<ConversationViewData, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
|
||||
|
||||
var mediaPreviewEnabled: Boolean
|
||||
get() = statusDisplayOptions.mediaPreviewEnabled
|
||||
set(mediaPreviewEnabled) {
|
||||
statusDisplayOptions = statusDisplayOptions.copy(
|
||||
mediaPreviewEnabled = mediaPreviewEnabled
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
|
||||
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false)
|
||||
return ConversationViewHolder(view, statusDisplayOptions, listener)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ConversationViewHolder, position: Int) {
|
||||
holder.setupWithConversation(getItem(position))
|
||||
onBindViewHolder(holder, position, emptyList())
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(
|
||||
holder: ConversationViewHolder,
|
||||
position: Int,
|
||||
payloads: List<Any>
|
||||
) {
|
||||
getItem(position)?.let { conversationViewData ->
|
||||
holder.setupWithConversation(conversationViewData, payloads.firstOrNull())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -44,7 +63,17 @@ class ConversationAdapter(
|
|||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
|
||||
return oldItem == newItem
|
||||
return false // Items are different always. It allows to refresh timestamp on every view holder update
|
||||
}
|
||||
|
||||
override fun getChangePayload(oldItem: ConversationViewData, newItem: ConversationViewData): Any? {
|
||||
return if (oldItem == newItem) {
|
||||
// If items are equal - update timestamp only
|
||||
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
|
||||
} else {
|
||||
// If items are different - update the whole view holder
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,6 +34,7 @@ import java.util.Date
|
|||
data class ConversationEntity(
|
||||
val accountId: Long,
|
||||
val id: String,
|
||||
val order: Int,
|
||||
val accounts: List<ConversationAccountEntity>,
|
||||
val unread: Boolean,
|
||||
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity
|
||||
|
@ -41,6 +42,7 @@ data class ConversationEntity(
|
|||
fun toViewData(): ConversationViewData {
|
||||
return ConversationViewData(
|
||||
id = id,
|
||||
order = order,
|
||||
accounts = accounts,
|
||||
unread = unread,
|
||||
lastStatus = lastStatus.toViewData()
|
||||
|
@ -50,6 +52,7 @@ data class ConversationEntity(
|
|||
|
||||
data class ConversationAccountEntity(
|
||||
val id: String,
|
||||
val localUsername: String,
|
||||
val username: String,
|
||||
val displayName: String,
|
||||
val avatar: String,
|
||||
|
@ -58,12 +61,12 @@ data class ConversationAccountEntity(
|
|||
fun toAccount(): TimelineAccount {
|
||||
return TimelineAccount(
|
||||
id = id,
|
||||
localUsername = localUsername,
|
||||
username = username,
|
||||
displayName = displayName,
|
||||
url = "",
|
||||
avatar = avatar,
|
||||
emojis = emojis,
|
||||
localUsername = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -79,6 +82,7 @@ data class ConversationStatusEntity(
|
|||
val createdAt: Date,
|
||||
val emojis: List<Emoji>,
|
||||
val favouritesCount: Int,
|
||||
val repliesCount: Int,
|
||||
val favourited: Boolean,
|
||||
val bookmarked: Boolean,
|
||||
val sensitive: Boolean,
|
||||
|
@ -107,6 +111,7 @@ data class ConversationStatusEntity(
|
|||
emojis = emojis,
|
||||
reblogsCount = 0,
|
||||
favouritesCount = favouritesCount,
|
||||
repliesCount = repliesCount,
|
||||
reblogged = false,
|
||||
favourited = favourited,
|
||||
bookmarked = bookmarked,
|
||||
|
@ -132,6 +137,7 @@ data class ConversationStatusEntity(
|
|||
fun TimelineAccount.toEntity() =
|
||||
ConversationAccountEntity(
|
||||
id = id,
|
||||
localUsername = localUsername,
|
||||
username = username,
|
||||
displayName = name,
|
||||
avatar = avatar,
|
||||
|
@ -149,6 +155,7 @@ fun Status.toEntity() =
|
|||
createdAt = createdAt,
|
||||
emojis = emojis,
|
||||
favouritesCount = favouritesCount,
|
||||
repliesCount = repliesCount,
|
||||
favourited = favourited,
|
||||
bookmarked = bookmarked,
|
||||
sensitive = sensitive,
|
||||
|
@ -163,10 +170,11 @@ fun Status.toEntity() =
|
|||
poll = poll
|
||||
)
|
||||
|
||||
fun Conversation.toEntity(accountId: Long) =
|
||||
fun Conversation.toEntity(accountId: Long, order: Int) =
|
||||
ConversationEntity(
|
||||
accountId = accountId,
|
||||
id = id,
|
||||
order = order,
|
||||
accounts = accounts.map { it.toEntity() },
|
||||
unread = unread,
|
||||
lastStatus = lastStatus!!.toEntity()
|
||||
|
|
|
@ -19,22 +19,35 @@ import android.view.LayoutInflater
|
|||
import android.view.ViewGroup
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.LoadStateAdapter
|
||||
import com.keylesspalace.tusky.adapter.NetworkStateViewHolder
|
||||
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
|
||||
class ConversationLoadStateAdapter(
|
||||
private val retryCallback: () -> Unit
|
||||
) : LoadStateAdapter<NetworkStateViewHolder>() {
|
||||
) : LoadStateAdapter<BindingHolder<ItemNetworkStateBinding>>() {
|
||||
|
||||
override fun onBindViewHolder(holder: NetworkStateViewHolder, loadState: LoadState) {
|
||||
holder.setUpWithNetworkState(loadState)
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemNetworkStateBinding>, loadState: LoadState) {
|
||||
val binding = holder.binding
|
||||
binding.progressBar.visible(loadState == LoadState.Loading)
|
||||
binding.retryButton.visible(loadState is LoadState.Error)
|
||||
val msg = if (loadState is LoadState.Error) {
|
||||
loadState.error.message
|
||||
} else {
|
||||
null
|
||||
}
|
||||
binding.errorMsg.visible(msg != null)
|
||||
binding.errorMsg.text = msg
|
||||
binding.retryButton.setOnClickListener {
|
||||
retryCallback()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
loadState: LoadState
|
||||
): NetworkStateViewHolder {
|
||||
): BindingHolder<ItemNetworkStateBinding> {
|
||||
val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return NetworkStateViewHolder(binding, retryCallback)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ import com.keylesspalace.tusky.viewdata.StatusViewData
|
|||
|
||||
data class ConversationViewData(
|
||||
val id: String,
|
||||
val order: Int,
|
||||
val accounts: List<ConversationAccountEntity>,
|
||||
val unread: Boolean,
|
||||
val lastStatus: StatusViewData.Concrete
|
||||
|
@ -37,6 +38,7 @@ data class ConversationViewData(
|
|||
return ConversationEntity(
|
||||
accountId = accountId,
|
||||
id = id,
|
||||
order = order,
|
||||
accounts = accounts,
|
||||
unread = unread,
|
||||
lastStatus = lastStatus.toConversationStatusEntity(
|
||||
|
@ -71,6 +73,7 @@ fun StatusViewData.Concrete.toConversationStatusEntity(
|
|||
createdAt = status.createdAt,
|
||||
emojis = status.emojis,
|
||||
favouritesCount = status.favouritesCount,
|
||||
repliesCount = status.repliesCount,
|
||||
favourited = favourited,
|
||||
bookmarked = bookmarked,
|
||||
sensitive = status.sensitive,
|
||||
|
|
|
@ -23,6 +23,8 @@ import android.widget.Button;
|
|||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.keylesspalace.tusky.R;
|
||||
|
@ -43,12 +45,12 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
|
||||
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
|
||||
|
||||
private TextView conversationNameTextView;
|
||||
private Button contentCollapseButton;
|
||||
private ImageView[] avatars;
|
||||
private final TextView conversationNameTextView;
|
||||
private final Button contentCollapseButton;
|
||||
private final ImageView[] avatars;
|
||||
|
||||
private StatusDisplayOptions statusDisplayOptions;
|
||||
private StatusActionListener listener;
|
||||
private final StatusDisplayOptions statusDisplayOptions;
|
||||
private final StatusActionListener listener;
|
||||
|
||||
ConversationViewHolder(View itemView,
|
||||
StatusDisplayOptions statusDisplayOptions,
|
||||
|
@ -64,7 +66,6 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
this.statusDisplayOptions = statusDisplayOptions;
|
||||
|
||||
this.listener = listener;
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -72,52 +73,67 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height);
|
||||
}
|
||||
|
||||
void setupWithConversation(ConversationViewData conversation) {
|
||||
void setupWithConversation(
|
||||
@NonNull ConversationViewData conversation,
|
||||
@Nullable Object payloads
|
||||
) {
|
||||
|
||||
StatusViewData.Concrete statusViewData = conversation.getLastStatus();
|
||||
Status status = statusViewData.getStatus();
|
||||
TimelineAccount account = status.getAccount();
|
||||
|
||||
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener);
|
||||
if (payloads == null) {
|
||||
TimelineAccount account = status.getAccount();
|
||||
|
||||
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
|
||||
setUsername(account.getUsername());
|
||||
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
|
||||
setIsReply(status.getInReplyToId() != null);
|
||||
setFavourited(status.getFavourited());
|
||||
setBookmarked(status.getBookmarked());
|
||||
List<Attachment> attachments = status.getAttachments();
|
||||
boolean sensitive = status.getSensitive();
|
||||
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
|
||||
setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
|
||||
statusDisplayOptions.useBlurhash());
|
||||
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener);
|
||||
|
||||
if (attachments.size() == 0) {
|
||||
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
|
||||
setUsername(account.getUsername());
|
||||
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
|
||||
setIsReply(status.getInReplyToId() != null);
|
||||
setFavourited(status.getFavourited());
|
||||
setBookmarked(status.getBookmarked());
|
||||
List<Attachment> attachments = status.getAttachments();
|
||||
boolean sensitive = status.getSensitive();
|
||||
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
|
||||
setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
|
||||
statusDisplayOptions.useBlurhash());
|
||||
|
||||
if (attachments.size() == 0) {
|
||||
hideSensitiveMediaWarning();
|
||||
}
|
||||
// Hide the unused label.
|
||||
for (TextView mediaLabel : mediaLabels) {
|
||||
mediaLabel.setVisibility(View.GONE);
|
||||
}
|
||||
} else {
|
||||
setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
|
||||
// Hide all unused views.
|
||||
mediaPreviews[0].setVisibility(View.GONE);
|
||||
mediaPreviews[1].setVisibility(View.GONE);
|
||||
mediaPreviews[2].setVisibility(View.GONE);
|
||||
mediaPreviews[3].setVisibility(View.GONE);
|
||||
hideSensitiveMediaWarning();
|
||||
}
|
||||
// Hide the unused label.
|
||||
for (TextView mediaLabel : mediaLabels) {
|
||||
mediaLabel.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
|
||||
statusDisplayOptions);
|
||||
|
||||
setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(),
|
||||
status.getMentions(), status.getTags(), status.getEmojis(),
|
||||
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);
|
||||
|
||||
setConversationName(conversation.getAccounts());
|
||||
|
||||
setAvatars(conversation.getAccounts());
|
||||
} else {
|
||||
setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
|
||||
// Hide all unused views.
|
||||
mediaPreviews[0].setVisibility(View.GONE);
|
||||
mediaPreviews[1].setVisibility(View.GONE);
|
||||
mediaPreviews[2].setVisibility(View.GONE);
|
||||
mediaPreviews[3].setVisibility(View.GONE);
|
||||
hideSensitiveMediaWarning();
|
||||
if (payloads instanceof List) {
|
||||
for (Object item : (List<?>) payloads) {
|
||||
if (Key.KEY_CREATED.equals(item)) {
|
||||
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
|
||||
statusDisplayOptions);
|
||||
|
||||
setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(),
|
||||
status.getMentions(), status.getTags(), status.getEmojis(),
|
||||
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);
|
||||
|
||||
setConversationName(conversation.getAccounts());
|
||||
|
||||
setAvatars(conversation.getAccounts());
|
||||
}
|
||||
|
||||
private void setConversationName(List<ConversationAccountEntity> accounts) {
|
||||
|
@ -169,4 +185,4 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
content.setFilters(NO_INPUT_FILTER);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,20 +22,27 @@ import android.view.ViewGroup
|
|||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.LoadState
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import autodispose2.androidx.lifecycle.autoDispose
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.StatusListActivity
|
||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.fragment.SFragment
|
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
|
@ -44,29 +51,31 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions
|
|||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.toDuration
|
||||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
private val viewModel: ConversationsViewModel by viewModels { viewModelFactory }
|
||||
|
||||
private val binding by viewBinding(FragmentTimelineBinding::bind)
|
||||
|
||||
private lateinit var adapter: ConversationAdapter
|
||||
private lateinit var loadStateAdapter: ConversationLoadStateAdapter
|
||||
|
||||
private var layoutManager: LinearLayoutManager? = null
|
||||
|
||||
private var initialRefreshDone: Boolean = false
|
||||
private var hideFab = false
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment_timeline, container, false)
|
||||
|
@ -89,56 +98,106 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
)
|
||||
|
||||
adapter = ConversationAdapter(statusDisplayOptions, this)
|
||||
loadStateAdapter = ConversationLoadStateAdapter(adapter::retry)
|
||||
|
||||
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
|
||||
layoutManager = LinearLayoutManager(view.context)
|
||||
binding.recyclerView.layoutManager = layoutManager
|
||||
binding.recyclerView.adapter = adapter.withLoadStateFooter(loadStateAdapter)
|
||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
|
||||
binding.progressBar.hide()
|
||||
binding.statusView.hide()
|
||||
setupRecyclerView()
|
||||
|
||||
initSwipeToRefresh()
|
||||
|
||||
adapter.addLoadStateListener { loadState ->
|
||||
if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
}
|
||||
|
||||
binding.statusView.hide()
|
||||
binding.progressBar.hide()
|
||||
|
||||
if (adapter.itemCount == 0) {
|
||||
when (loadState.refresh) {
|
||||
is LoadState.NotLoading -> {
|
||||
if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) {
|
||||
binding.statusView.show()
|
||||
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
|
||||
}
|
||||
}
|
||||
is LoadState.Error -> {
|
||||
binding.statusView.show()
|
||||
|
||||
if ((loadState.refresh as LoadState.Error).error is IOException) {
|
||||
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null)
|
||||
} else {
|
||||
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null)
|
||||
}
|
||||
}
|
||||
is LoadState.Loading -> {
|
||||
binding.progressBar.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
|
||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||
if (positionStart == 0 && adapter.itemCount != itemCount) {
|
||||
binding.recyclerView.post {
|
||||
if (getView() != null) {
|
||||
binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
||||
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
|
||||
val composeButton = (activity as ActionButtonActivity).actionButton
|
||||
if (composeButton != null) {
|
||||
if (hideFab) {
|
||||
if (dy > 0 && composeButton.isShown) {
|
||||
composeButton.hide() // hides the button if we're scrolling down
|
||||
} else if (dy < 0 && !composeButton.isShown) {
|
||||
composeButton.show() // shows it if we are scrolling up
|
||||
}
|
||||
} else if (!composeButton.isShown) {
|
||||
composeButton.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.conversationFlow.collectLatest { pagingData ->
|
||||
adapter.submitData(pagingData)
|
||||
}
|
||||
}
|
||||
|
||||
adapter.addLoadStateListener { loadStates ->
|
||||
|
||||
loadStates.refresh.let { refreshState ->
|
||||
if (refreshState is LoadState.Error) {
|
||||
binding.statusView.show()
|
||||
if (refreshState.error is IOException) {
|
||||
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||
adapter.refresh()
|
||||
}
|
||||
} else {
|
||||
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
adapter.refresh()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
binding.statusView.hide()
|
||||
}
|
||||
|
||||
binding.progressBar.visible(refreshState == LoadState.Loading && adapter.itemCount == 0)
|
||||
|
||||
if (refreshState is LoadState.NotLoading && !initialRefreshDone) {
|
||||
// jump to top after the initial refresh finished
|
||||
binding.recyclerView.scrollToPosition(0)
|
||||
initialRefreshDone = true
|
||||
}
|
||||
|
||||
if (refreshState != LoadState.Loading) {
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
}
|
||||
lifecycleScope.launchWhenResumed {
|
||||
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
|
||||
while (!useAbsoluteTime) {
|
||||
adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED))
|
||||
delay(1.toDuration(DurationUnit.MINUTES))
|
||||
}
|
||||
}
|
||||
|
||||
eventHub.events
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
|
||||
.subscribe { event ->
|
||||
if (event is PreferenceChangedEvent) {
|
||||
onPreferenceChanged(event.preferenceKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(context)
|
||||
|
||||
binding.recyclerView.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
|
||||
|
||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
|
||||
binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
|
||||
}
|
||||
|
||||
private fun initSwipeToRefresh() {
|
||||
|
@ -201,7 +260,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
}
|
||||
|
||||
override fun onOpenReblog(position: Int) {
|
||||
// there are no reblogs in search results
|
||||
// there are no reblogs in conversations
|
||||
}
|
||||
|
||||
override fun onExpandedChange(expanded: Boolean, position: Int) {
|
||||
|
@ -246,6 +305,19 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
}
|
||||
}
|
||||
|
||||
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
|
||||
adapter.peek(position)?.let { conversation ->
|
||||
viewModel.voteInPoll(choices, conversation)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReselect() {
|
||||
if (isAdded) {
|
||||
binding.recyclerView.layoutManager?.scrollToPosition(0)
|
||||
binding.recyclerView.stopScroll()
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteConversation(conversation: ConversationViewData) {
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setMessage(R.string.dialog_delete_conversation_warning)
|
||||
|
@ -256,20 +328,20 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
.show()
|
||||
}
|
||||
|
||||
private fun jumpToTop() {
|
||||
if (isAdded) {
|
||||
layoutManager?.scrollToPosition(0)
|
||||
binding.recyclerView.stopScroll()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReselect() {
|
||||
jumpToTop()
|
||||
}
|
||||
|
||||
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
|
||||
adapter.peek(position)?.let { conversation ->
|
||||
viewModel.voteInPoll(choices, conversation)
|
||||
private fun onPreferenceChanged(key: String) {
|
||||
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
when (key) {
|
||||
PrefKeys.FAB_HIDE -> {
|
||||
hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
||||
}
|
||||
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
|
||||
val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
|
||||
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
|
||||
if (enabled != oldMediaPreviewEnabled) {
|
||||
adapter.mediaPreviewEnabled = enabled
|
||||
adapter.notifyItemRangeChanged(0, adapter.itemCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,8 +4,11 @@ import androidx.paging.ExperimentalPagingApi
|
|||
import androidx.paging.LoadType
|
||||
import androidx.paging.PagingState
|
||||
import androidx.paging.RemoteMediator
|
||||
import androidx.room.withTransaction
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.HttpHeaderLink
|
||||
import retrofit2.HttpException
|
||||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
class ConversationsRemoteMediator(
|
||||
|
@ -14,38 +17,53 @@ class ConversationsRemoteMediator(
|
|||
private val db: AppDatabase
|
||||
) : RemoteMediator<Int, ConversationEntity>() {
|
||||
|
||||
private var nextKey: String? = null
|
||||
|
||||
private var order: Int = 0
|
||||
|
||||
override suspend fun load(
|
||||
loadType: LoadType,
|
||||
state: PagingState<Int, ConversationEntity>
|
||||
): MediatorResult {
|
||||
|
||||
if (loadType == LoadType.PREPEND) {
|
||||
return MediatorResult.Success(endOfPaginationReached = true)
|
||||
}
|
||||
|
||||
if (loadType == LoadType.REFRESH) {
|
||||
nextKey = null
|
||||
order = 0
|
||||
}
|
||||
|
||||
try {
|
||||
val conversationsResult = when (loadType) {
|
||||
LoadType.REFRESH -> {
|
||||
api.getConversations(limit = state.config.initialLoadSize)
|
||||
}
|
||||
LoadType.PREPEND -> {
|
||||
return MediatorResult.Success(endOfPaginationReached = true)
|
||||
}
|
||||
LoadType.APPEND -> {
|
||||
val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.lastStatus?.id
|
||||
api.getConversations(maxId = maxId, limit = state.config.pageSize)
|
||||
}
|
||||
val conversationsResponse = api.getConversations(maxId = nextKey, limit = state.config.pageSize)
|
||||
|
||||
val conversations = conversationsResponse.body()
|
||||
if (!conversationsResponse.isSuccessful || conversations == null) {
|
||||
return MediatorResult.Error(HttpException(conversationsResponse))
|
||||
}
|
||||
|
||||
if (loadType == LoadType.REFRESH) {
|
||||
db.conversationDao().deleteForAccount(accountId)
|
||||
db.withTransaction {
|
||||
|
||||
if (loadType == LoadType.REFRESH) {
|
||||
db.conversationDao().deleteForAccount(accountId)
|
||||
}
|
||||
|
||||
val linkHeader = conversationsResponse.headers()["Link"]
|
||||
val links = HttpHeaderLink.parse(linkHeader)
|
||||
nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id")
|
||||
|
||||
db.conversationDao().insert(
|
||||
conversations
|
||||
.filterNot { it.lastStatus == null }
|
||||
.map {
|
||||
it.toEntity(accountId, order++)
|
||||
}
|
||||
)
|
||||
}
|
||||
db.conversationDao().insert(
|
||||
conversationsResult
|
||||
.filterNot { it.lastStatus == null }
|
||||
.map { it.toEntity(accountId) }
|
||||
)
|
||||
return MediatorResult.Success(endOfPaginationReached = conversationsResult.isEmpty())
|
||||
return MediatorResult.Success(endOfPaginationReached = nextKey == null)
|
||||
} catch (e: Exception) {
|
||||
return MediatorResult.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun initialize() = InitializeAction.LAUNCH_INITIAL_REFRESH
|
||||
}
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* 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.components.conversation
|
||||
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ConversationsRepository @Inject constructor(
|
||||
val mastodonApi: MastodonApi,
|
||||
val db: AppDatabase
|
||||
) {
|
||||
|
||||
fun deleteCacheForAccount(accountId: Long) {
|
||||
Single.fromCallable {
|
||||
db.conversationDao().deleteForAccount(accountId)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
}
|
||||
}
|
|
@ -26,7 +26,7 @@ import androidx.paging.map
|
|||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.await
|
||||
|
@ -41,7 +41,7 @@ class ConversationsViewModel @Inject constructor(
|
|||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
val conversationFlow = Pager(
|
||||
config = PagingConfig(pageSize = 10, enablePlaceholders = false, initialLoadSize = 20),
|
||||
config = PagingConfig(pageSize = 30),
|
||||
remoteMediator = ConversationsRemoteMediator(accountManager.activeAccount!!.id, api, database),
|
||||
pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) }
|
||||
)
|
||||
|
|
|
@ -16,6 +16,9 @@
|
|||
package com.keylesspalace.tusky.components.instanceinfo
|
||||
|
||||
import android.util.Log
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.calladapter.networkresult.getOrElse
|
||||
import at.connyduck.calladapter.networkresult.onSuccess
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.EmojisEntity
|
||||
|
|
|
@ -27,6 +27,7 @@ import android.widget.TextView
|
|||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.bumptech.glide.Glide
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.BuildConfig
|
||||
|
@ -34,6 +35,7 @@ import com.keylesspalace.tusky.MainActivity
|
|||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ActivityLoginBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.entity.AccessToken
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.getNonNullString
|
||||
import com.keylesspalace.tusky.util.rickRoll
|
||||
|
@ -93,12 +95,17 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
|
||||
if (savedInstanceState == null &&
|
||||
BuildConfig.CUSTOM_INSTANCE.isNotBlank() &&
|
||||
!isAdditionalLogin()
|
||||
!isAdditionalLogin() && !isAccountMigration()
|
||||
) {
|
||||
binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE)
|
||||
binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length)
|
||||
}
|
||||
|
||||
if (isAccountMigration()) {
|
||||
binding.domainEditText.setText(accountManager.activeAccount!!.domain)
|
||||
binding.domainEditText.isEnabled = false
|
||||
}
|
||||
|
||||
if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) {
|
||||
Glide.with(binding.loginLogo)
|
||||
.load(BuildConfig.CUSTOM_LOGO_URL)
|
||||
|
@ -122,7 +129,7 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
textView?.movementMethod = LinkMovementMethod.getInstance()
|
||||
}
|
||||
|
||||
if (isAdditionalLogin()) {
|
||||
if (isAdditionalLogin() || isAccountMigration()) {
|
||||
setSupportActionBar(binding.toolbar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setDisplayShowTitleEnabled(false)
|
||||
|
@ -137,7 +144,7 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
|
||||
override fun finish() {
|
||||
super.finish()
|
||||
if (isAdditionalLogin()) {
|
||||
if (isAdditionalLogin() || isAccountMigration()) {
|
||||
overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right)
|
||||
}
|
||||
}
|
||||
|
@ -244,26 +251,50 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code"
|
||||
).fold(
|
||||
{ accessToken ->
|
||||
accountManager.addAccount(accessToken.accessToken, domain)
|
||||
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
startActivity(intent)
|
||||
finish()
|
||||
overridePendingTransition(R.anim.explode, R.anim.explode)
|
||||
fetchAccountDetails(accessToken, domain, clientId, clientSecret)
|
||||
},
|
||||
{ e ->
|
||||
setLoading(false)
|
||||
binding.domainTextInputLayout.error =
|
||||
getString(R.string.error_retrieving_oauth_token)
|
||||
Log.e(
|
||||
TAG,
|
||||
"%s %s".format(getString(R.string.error_retrieving_oauth_token), e.message),
|
||||
)
|
||||
Log.e(TAG, getString(R.string.error_retrieving_oauth_token), e)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun fetchAccountDetails(
|
||||
accessToken: AccessToken,
|
||||
domain: String,
|
||||
clientId: String,
|
||||
clientSecret: String
|
||||
) {
|
||||
|
||||
mastodonApi.accountVerifyCredentials(
|
||||
domain = domain,
|
||||
auth = "Bearer ${accessToken.accessToken}"
|
||||
).fold({ newAccount ->
|
||||
accountManager.addAccount(
|
||||
accessToken = accessToken.accessToken,
|
||||
domain = domain,
|
||||
clientId = clientId,
|
||||
clientSecret = clientSecret,
|
||||
oauthScopes = OAUTH_SCOPES,
|
||||
newAccount = newAccount
|
||||
)
|
||||
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
startActivity(intent)
|
||||
finish()
|
||||
overridePendingTransition(R.anim.explode, R.anim.explode)
|
||||
}, { e ->
|
||||
setLoading(false)
|
||||
binding.domainTextInputLayout.error =
|
||||
getString(R.string.error_loading_account_details)
|
||||
Log.e(TAG, getString(R.string.error_loading_account_details), e)
|
||||
})
|
||||
}
|
||||
|
||||
private fun setLoading(loadingState: Boolean) {
|
||||
if (loadingState) {
|
||||
binding.loginLoadingLayout.visibility = View.VISIBLE
|
||||
|
@ -276,19 +307,28 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
}
|
||||
|
||||
private fun isAdditionalLogin(): Boolean {
|
||||
return intent.getBooleanExtra(LOGIN_MODE, false)
|
||||
return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_ADDITIONAL_LOGIN
|
||||
}
|
||||
|
||||
private fun isAccountMigration(): Boolean {
|
||||
return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_MIGRATION
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LoginActivity" // logging tag
|
||||
private const val OAUTH_SCOPES = "read write follow"
|
||||
private const val OAUTH_SCOPES = "read write follow push"
|
||||
private const val LOGIN_MODE = "LOGIN_MODE"
|
||||
private const val DOMAIN = "domain"
|
||||
private const val CLIENT_ID = "clientId"
|
||||
private const val CLIENT_SECRET = "clientSecret"
|
||||
|
||||
const val MODE_DEFAULT = 0
|
||||
const val MODE_ADDITIONAL_LOGIN = 1
|
||||
// "Migration" is used to update the OAuth scope granted to the client
|
||||
const val MODE_MIGRATION = 2
|
||||
|
||||
@JvmStatic
|
||||
fun getIntent(context: Context, mode: Boolean): Intent {
|
||||
fun getIntent(context: Context, mode: Int): Intent {
|
||||
val loginIntent = Intent(context, LoginActivity::class.java)
|
||||
loginIntent.putExtra(LOGIN_MODE, mode)
|
||||
return loginIntent
|
||||
|
|
|
@ -83,6 +83,10 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
|
|||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
WebView.setWebContentsDebuggingEnabled(true)
|
||||
}
|
||||
|
||||
val data = OauthLogin.parseData(intent)
|
||||
|
||||
setContentView(binding.root)
|
||||
|
|
|
@ -57,6 +57,7 @@ import com.keylesspalace.tusky.entity.Notification;
|
|||
import com.keylesspalace.tusky.entity.Poll;
|
||||
import com.keylesspalace.tusky.entity.PollOption;
|
||||
import com.keylesspalace.tusky.entity.Status;
|
||||
import com.keylesspalace.tusky.network.MastodonApi;
|
||||
import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver;
|
||||
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver;
|
||||
import com.keylesspalace.tusky.util.StringUtils;
|
||||
|
@ -457,24 +458,6 @@ public class NotificationHelper {
|
|||
}
|
||||
}
|
||||
|
||||
public static void deleteLegacyNotificationChannels(@NonNull Context context, @NonNull AccountManager accountManager) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
// used until Tusky 1.4
|
||||
notificationManager.deleteNotificationChannel(CHANNEL_MENTION);
|
||||
notificationManager.deleteNotificationChannel(CHANNEL_FAVOURITE);
|
||||
notificationManager.deleteNotificationChannel(CHANNEL_BOOST);
|
||||
notificationManager.deleteNotificationChannel(CHANNEL_FOLLOW);
|
||||
|
||||
// used until Tusky 1.7
|
||||
for(AccountEntity account: accountManager.getAllAccountsOrderedByActive()) {
|
||||
notificationManager.deleteNotificationChannel(CHANNEL_FAVOURITE+" "+account.getIdentifier());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean areNotificationsEnabled(@NonNull Context context, @NonNull AccountManager accountManager) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
|
@ -539,13 +522,18 @@ public class NotificationHelper {
|
|||
}
|
||||
}
|
||||
|
||||
private static boolean filterNotification(AccountEntity account, Notification notification,
|
||||
public static boolean filterNotification(AccountEntity account, Notification notification,
|
||||
Context context) {
|
||||
return filterNotification(account, notification.getType(), context);
|
||||
}
|
||||
|
||||
public static boolean filterNotification(AccountEntity account, Notification.Type type,
|
||||
Context context) {
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
String channelId = getChannelId(account, notification);
|
||||
String channelId = getChannelId(account, type);
|
||||
if(channelId == null) {
|
||||
// unknown notificationtype
|
||||
return false;
|
||||
|
@ -554,7 +542,7 @@ public class NotificationHelper {
|
|||
return channel.getImportance() > NotificationManager.IMPORTANCE_NONE;
|
||||
}
|
||||
|
||||
switch (notification.getType()) {
|
||||
switch (type) {
|
||||
case MENTION:
|
||||
return account.getNotificationsMentioned();
|
||||
case STATUS:
|
||||
|
@ -580,7 +568,12 @@ public class NotificationHelper {
|
|||
|
||||
@Nullable
|
||||
private static String getChannelId(AccountEntity account, Notification notification) {
|
||||
switch (notification.getType()) {
|
||||
return getChannelId(account, notification.getType());
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String getChannelId(AccountEntity account, Notification.Type type) {
|
||||
switch (type) {
|
||||
case MENTION:
|
||||
return CHANNEL_MENTION + account.getIdentifier();
|
||||
case STATUS:
|
||||
|
|
|
@ -0,0 +1,229 @@
|
|||
/* Copyright 2022 Tusky contributors
|
||||
*
|
||||
* 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>. */
|
||||
|
||||
@file:JvmName("PushNotificationHelper")
|
||||
package com.keylesspalace.tusky.components.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.preference.PreferenceManager
|
||||
import at.connyduck.calladapter.networkresult.onFailure
|
||||
import at.connyduck.calladapter.networkresult.onSuccess
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.login.LoginActivity
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.CryptoUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
import retrofit2.HttpException
|
||||
|
||||
private const val TAG = "PushNotificationHelper"
|
||||
|
||||
private const val KEY_MIGRATION_NOTICE_DISMISSED = "migration_notice_dismissed"
|
||||
|
||||
private fun anyAccountNeedsMigration(accountManager: AccountManager): Boolean =
|
||||
accountManager.accounts.any(::accountNeedsMigration)
|
||||
|
||||
private fun accountNeedsMigration(account: AccountEntity): Boolean =
|
||||
!account.oauthScopes.contains("push")
|
||||
|
||||
fun currentAccountNeedsMigration(accountManager: AccountManager): Boolean =
|
||||
accountManager.activeAccount?.let(::accountNeedsMigration) ?: false
|
||||
|
||||
fun showMigrationNoticeIfNecessary(
|
||||
context: Context,
|
||||
parent: View,
|
||||
anchorView: View?,
|
||||
accountManager: AccountManager
|
||||
) {
|
||||
// No point showing anything if we cannot enable it
|
||||
if (!isUnifiedPushAvailable(context)) return
|
||||
if (!anyAccountNeedsMigration(accountManager)) return
|
||||
|
||||
val pm = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
if (pm.getBoolean(KEY_MIGRATION_NOTICE_DISMISSED, false)) return
|
||||
|
||||
Snackbar.make(parent, R.string.tips_push_notification_migration, Snackbar.LENGTH_INDEFINITE)
|
||||
.setAnchorView(anchorView)
|
||||
.setAction(R.string.action_details) { showMigrationExplanationDialog(context, accountManager) }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showMigrationExplanationDialog(context: Context, accountManager: AccountManager) {
|
||||
AlertDialog.Builder(context).apply {
|
||||
if (currentAccountNeedsMigration(accountManager)) {
|
||||
setMessage(R.string.dialog_push_notification_migration)
|
||||
setPositiveButton(R.string.title_migration_relogin) { _, _ ->
|
||||
context.startActivity(LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION))
|
||||
}
|
||||
} else {
|
||||
setMessage(R.string.dialog_push_notification_migration_other_accounts)
|
||||
}
|
||||
setNegativeButton(R.string.action_dismiss) { dialog, _ ->
|
||||
val pm = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
pm.edit().putBoolean(KEY_MIGRATION_NOTICE_DISMISSED, true).apply()
|
||||
dialog.dismiss()
|
||||
}
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun enableUnifiedPushNotificationsForAccount(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity) {
|
||||
if (isUnifiedPushNotificationEnabledForAccount(account)) {
|
||||
// Already registered, update the subscription to match notification settings
|
||||
updateUnifiedPushSubscription(context, api, accountManager, account)
|
||||
} else {
|
||||
UnifiedPush.registerAppWithDialog(context, account.id.toString(), features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE))
|
||||
}
|
||||
}
|
||||
|
||||
fun disableUnifiedPushNotificationsForAccount(context: Context, account: AccountEntity) {
|
||||
if (!isUnifiedPushNotificationEnabledForAccount(account)) {
|
||||
// Not registered
|
||||
return
|
||||
}
|
||||
|
||||
UnifiedPush.unregisterApp(context, account.id.toString())
|
||||
}
|
||||
|
||||
fun isUnifiedPushNotificationEnabledForAccount(account: AccountEntity): Boolean =
|
||||
account.unifiedPushUrl.isNotEmpty()
|
||||
|
||||
private fun isUnifiedPushAvailable(context: Context): Boolean =
|
||||
UnifiedPush.getDistributors(context).isNotEmpty()
|
||||
|
||||
fun canEnablePushNotifications(context: Context, accountManager: AccountManager): Boolean =
|
||||
isUnifiedPushAvailable(context) && !anyAccountNeedsMigration(accountManager)
|
||||
|
||||
suspend fun enablePushNotificationsWithFallback(context: Context, api: MastodonApi, accountManager: AccountManager) {
|
||||
if (!canEnablePushNotifications(context, accountManager)) {
|
||||
// No UP distributors
|
||||
NotificationHelper.enablePullNotifications(context)
|
||||
return
|
||||
}
|
||||
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
accountManager.accounts.forEach {
|
||||
val notificationGroupEnabled = Build.VERSION.SDK_INT < 28 ||
|
||||
nm.getNotificationChannelGroup(it.identifier)?.isBlocked == false
|
||||
val shouldEnable = it.notificationsEnabled && notificationGroupEnabled
|
||||
|
||||
if (shouldEnable) {
|
||||
enableUnifiedPushNotificationsForAccount(context, api, accountManager, it)
|
||||
} else {
|
||||
disableUnifiedPushNotificationsForAccount(context, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun disablePushNotifications(context: Context, accountManager: AccountManager) {
|
||||
accountManager.accounts.forEach {
|
||||
disableUnifiedPushNotificationsForAccount(context, it)
|
||||
}
|
||||
}
|
||||
|
||||
fun disableAllNotifications(context: Context, accountManager: AccountManager) {
|
||||
disablePushNotifications(context, accountManager)
|
||||
NotificationHelper.disablePullNotifications(context)
|
||||
}
|
||||
|
||||
private fun buildSubscriptionData(context: Context, account: AccountEntity): Map<String, Boolean> =
|
||||
buildMap {
|
||||
Notification.Type.asList.forEach {
|
||||
put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(account, it, context))
|
||||
}
|
||||
}
|
||||
|
||||
// Called by UnifiedPush callback
|
||||
suspend fun registerUnifiedPushEndpoint(
|
||||
context: Context,
|
||||
api: MastodonApi,
|
||||
accountManager: AccountManager,
|
||||
account: AccountEntity,
|
||||
endpoint: String
|
||||
) = withContext(Dispatchers.IO) {
|
||||
|
||||
// Generate a prime256v1 key pair for WebPush
|
||||
// Decryption is unimplemented for now, since Mastodon uses an old WebPush
|
||||
// standard which does not send needed information for decryption in the payload
|
||||
// This makes it not directly compatible with UnifiedPush
|
||||
// As of now, we use it purely as a way to trigger a pull
|
||||
val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1)
|
||||
val auth = CryptoUtil.secureRandomBytesEncoded(16)
|
||||
|
||||
api.subscribePushNotifications(
|
||||
"Bearer ${account.accessToken}", account.domain,
|
||||
endpoint, keyPair.pubkey, auth,
|
||||
buildSubscriptionData(context, account)
|
||||
).onFailure { throwable ->
|
||||
Log.w(TAG, "Error setting push endpoint for account ${account.id}", throwable)
|
||||
disableUnifiedPushNotificationsForAccount(context, account)
|
||||
}.onSuccess {
|
||||
Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}")
|
||||
|
||||
account.pushPubKey = keyPair.pubkey
|
||||
account.pushPrivKey = keyPair.privKey
|
||||
account.pushAuth = auth
|
||||
account.pushServerKey = it.serverKey
|
||||
account.unifiedPushUrl = endpoint
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronize the enabled / disabled state of notifications with server-side subscription
|
||||
suspend fun updateUnifiedPushSubscription(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity) {
|
||||
withContext(Dispatchers.IO) {
|
||||
api.updatePushNotificationSubscription(
|
||||
"Bearer ${account.accessToken}", account.domain,
|
||||
buildSubscriptionData(context, account)
|
||||
).onSuccess {
|
||||
Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}")
|
||||
|
||||
account.pushServerKey = it.serverKey
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unregisterUnifiedPushEndpoint(api: MastodonApi, accountManager: AccountManager, account: AccountEntity) {
|
||||
withContext(Dispatchers.IO) {
|
||||
api.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain)
|
||||
.onFailure {
|
||||
Log.d(TAG, "Error unregistering push endpoint for account " + account.id)
|
||||
Log.d(TAG, Log.getStackTraceString(it))
|
||||
Log.d(TAG, (it as HttpException).response().toString())
|
||||
}
|
||||
.onSuccess {
|
||||
Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id)
|
||||
// Clear the URL in database
|
||||
account.unifiedPushUrl = ""
|
||||
account.pushServerKey = ""
|
||||
account.pushAuth = ""
|
||||
account.pushPrivKey = ""
|
||||
account.pushPubKey = ""
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -23,6 +23,7 @@ import androidx.annotation.DrawableRes
|
|||
import androidx.preference.PreferenceFragmentCompat
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.AccountListActivity
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.BuildConfig
|
||||
import com.keylesspalace.tusky.FiltersActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
|
@ -30,6 +31,8 @@ import com.keylesspalace.tusky.TabPreferenceActivity
|
|||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
|
||||
import com.keylesspalace.tusky.components.login.LoginActivity
|
||||
import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigration
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
|
@ -139,6 +142,18 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
}
|
||||
}
|
||||
|
||||
if (currentAccountNeedsMigration(accountManager)) {
|
||||
preference {
|
||||
setTitle(R.string.title_migration_relogin)
|
||||
setIcon(R.drawable.ic_logout)
|
||||
setOnPreferenceClickListener {
|
||||
val intent = LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION)
|
||||
(activity as BaseActivity).startActivityWithSlideInAnimation(intent)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
preferenceCategory(R.string.pref_publishing) {
|
||||
listPreference {
|
||||
setTitle(R.string.pref_default_post_privacy)
|
||||
|
|
|
@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope
|
|||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.cachedIn
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.entity.ScheduledStatus
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
|
|
|
@ -27,7 +27,7 @@ import com.keylesspalace.tusky.entity.DeletedStatus
|
|||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||
import com.keylesspalace.tusky.util.RxAwareViewModel
|
||||
import com.keylesspalace.tusky.util.toViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
|
|
|
@ -103,9 +103,6 @@ class TimelineFragment :
|
|||
private lateinit var adapter: TimelinePagingAdapter
|
||||
|
||||
private var isSwipeToRefreshEnabled = true
|
||||
|
||||
private var layoutManager: LinearLayoutManager? = null
|
||||
private var scrollListener: RecyclerView.OnScrollListener? = null
|
||||
private var hideFab = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
@ -226,7 +223,7 @@ class TimelineFragment :
|
|||
if (actionButtonPresent()) {
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
hideFab = preferences.getBoolean("fabHide", false)
|
||||
scrollListener = object : RecyclerView.OnScrollListener() {
|
||||
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
|
||||
val composeButton = (activity as ActionButtonActivity).actionButton
|
||||
if (composeButton != null) {
|
||||
|
@ -241,9 +238,7 @@ class TimelineFragment :
|
|||
}
|
||||
}
|
||||
}
|
||||
}.also {
|
||||
binding.recyclerView.addOnScrollListener(it)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
eventHub.events
|
||||
|
@ -279,8 +274,7 @@ class TimelineFragment :
|
|||
}
|
||||
)
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
binding.recyclerView.layoutManager = layoutManager
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(context)
|
||||
val divider = DividerItemDecoration(context, RecyclerView.VERTICAL)
|
||||
binding.recyclerView.addItemDecoration(divider)
|
||||
|
||||
|
@ -471,7 +465,7 @@ class TimelineFragment :
|
|||
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
|
||||
if (!useAbsoluteTime) {
|
||||
Observable.interval(1, TimeUnit.MINUTES)
|
||||
Observable.interval(0, 1, TimeUnit.MINUTES)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(this, Lifecycle.Event.ON_PAUSE)
|
||||
.subscribe {
|
||||
|
@ -482,7 +476,7 @@ class TimelineFragment :
|
|||
|
||||
override fun onReselect() {
|
||||
if (isAdded) {
|
||||
layoutManager!!.scrollToPosition(0)
|
||||
binding.recyclerView.layoutManager?.scrollToPosition(0)
|
||||
binding.recyclerView.stopScroll()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -99,6 +99,7 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
|
|||
contentShowing = false,
|
||||
pinned = false,
|
||||
card = null,
|
||||
repliesCount = 0
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -140,6 +141,7 @@ fun Status.toEntity(
|
|||
contentCollapsed = contentCollapsed,
|
||||
pinned = actionableStatus.pinned == true,
|
||||
card = actionableStatus.card?.let(gson::toJson),
|
||||
repliesCount = actionableStatus.repliesCount
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -183,6 +185,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
|
|||
muted = status.muted,
|
||||
poll = poll,
|
||||
card = card,
|
||||
repliesCount = status.repliesCount
|
||||
)
|
||||
}
|
||||
val status = if (reblog != null) {
|
||||
|
@ -211,7 +214,8 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
|
|||
pinned = status.pinned,
|
||||
muted = status.muted,
|
||||
poll = null,
|
||||
card = null
|
||||
card = null,
|
||||
repliesCount = status.repliesCount,
|
||||
)
|
||||
} else {
|
||||
Status(
|
||||
|
@ -240,6 +244,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
|
|||
muted = status.muted,
|
||||
poll = poll,
|
||||
card = card,
|
||||
repliesCount = status.repliesCount,
|
||||
)
|
||||
}
|
||||
return StatusViewData.Concrete(
|
||||
|
|
|
@ -51,6 +51,10 @@ class CachedTimelineRemoteMediator(
|
|||
state: PagingState<Int, TimelineStatusWithAccount>
|
||||
): MediatorResult {
|
||||
|
||||
if (!activeAccount.isLoggedIn()) {
|
||||
return MediatorResult.Success(endOfPaginationReached = true)
|
||||
}
|
||||
|
||||
try {
|
||||
var dbEmpty = false
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope
|
|||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.filter
|
||||
import androidx.paging.map
|
||||
|
@ -37,10 +38,11 @@ import com.keylesspalace.tusky.components.timeline.toViewData
|
|||
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.network.FilterModel
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asExecutor
|
||||
|
@ -66,7 +68,16 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
filterModel: FilterModel,
|
||||
private val db: AppDatabase,
|
||||
private val gson: Gson
|
||||
) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel) {
|
||||
) : TimelineViewModel(
|
||||
timelineCases,
|
||||
api,
|
||||
eventHub,
|
||||
accountManager,
|
||||
sharedPreferences,
|
||||
filterModel
|
||||
) {
|
||||
|
||||
private var currentPagingSource: PagingSource<Int, TimelineStatusWithAccount>? = null
|
||||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
override val statuses = Pager(
|
||||
|
@ -78,6 +89,8 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
EmptyTimelinePagingSource()
|
||||
} else {
|
||||
db.timelineDao().getStatuses(activeAccount.id)
|
||||
}.also { newPagingSource ->
|
||||
this.currentPagingSource = newPagingSource
|
||||
}
|
||||
}
|
||||
).flow
|
||||
|
@ -113,13 +126,15 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
|
||||
override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) {
|
||||
viewModelScope.launch {
|
||||
db.timelineDao().setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing)
|
||||
db.timelineDao()
|
||||
.setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing)
|
||||
}
|
||||
}
|
||||
|
||||
override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
|
||||
viewModelScope.launch {
|
||||
db.timelineDao().setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed)
|
||||
db.timelineDao()
|
||||
.setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -146,12 +161,21 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
|
||||
val activeAccount = accountManager.activeAccount!!
|
||||
|
||||
timelineDao.insertStatus(Placeholder(placeholderId, loading = true).toEntity(activeAccount.id))
|
||||
timelineDao.insertStatus(
|
||||
Placeholder(placeholderId, loading = true).toEntity(
|
||||
activeAccount.id
|
||||
)
|
||||
)
|
||||
|
||||
val response = db.withTransaction {
|
||||
val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId)
|
||||
val nextPlaceholderId = timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId)
|
||||
api.homeTimeline(maxId = idAbovePlaceholder, sinceId = nextPlaceholderId, limit = LOAD_AT_ONCE)
|
||||
val nextPlaceholderId =
|
||||
timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId)
|
||||
api.homeTimeline(
|
||||
maxId = idAbovePlaceholder,
|
||||
sinceId = nextPlaceholderId,
|
||||
limit = LOAD_AT_ONCE
|
||||
)
|
||||
}.await()
|
||||
|
||||
val statuses = response.body()
|
||||
|
@ -165,16 +189,21 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
timelineDao.delete(activeAccount.id, placeholderId)
|
||||
|
||||
val overlappedStatuses = if (statuses.isNotEmpty()) {
|
||||
timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id)
|
||||
timelineDao.deleteRange(
|
||||
activeAccount.id,
|
||||
statuses.last().id,
|
||||
statuses.first().id
|
||||
)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
for (status in statuses) {
|
||||
timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson))
|
||||
status.reblog?.account?.toEntity(activeAccount.id, gson)?.let { rebloggedAccount ->
|
||||
timelineDao.insertAccount(rebloggedAccount)
|
||||
}
|
||||
status.reblog?.account?.toEntity(activeAccount.id, gson)
|
||||
?.let { rebloggedAccount ->
|
||||
timelineDao.insertAccount(rebloggedAccount)
|
||||
}
|
||||
timelineDao.insertStatus(
|
||||
status.toEntity(
|
||||
timelineUserId = activeAccount.id,
|
||||
|
@ -193,7 +222,10 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
to guarantee the placeholder has an id that exists on the server as not all
|
||||
servers handle client generated ids as expected */
|
||||
timelineDao.insertStatus(
|
||||
Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id)
|
||||
Placeholder(
|
||||
statuses.last().id,
|
||||
loading = false
|
||||
).toEntity(activeAccount.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -208,7 +240,8 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
private suspend fun loadMoreFailed(placeholderId: String, e: Exception) {
|
||||
Log.w("CachedTimelineVM", "failed loading statuses", e)
|
||||
val activeAccount = accountManager.activeAccount!!
|
||||
db.timelineDao().insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id))
|
||||
db.timelineDao()
|
||||
.insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id))
|
||||
}
|
||||
|
||||
override fun handleReblogEvent(reblogEvent: ReblogEvent) {
|
||||
|
@ -234,6 +267,13 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun invalidate() {
|
||||
// invalidating when we don't have statuses yet can cause empty timelines because it cancels the network load
|
||||
if (db.timelineDao().getStatusCount(accountManager.activeAccount!!.id) > 0) {
|
||||
currentPagingSource?.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_STATUSES_IN_CACHE = 1000
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ import com.keylesspalace.tusky.entity.Poll
|
|||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.FilterModel
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||
import com.keylesspalace.tusky.util.getDomain
|
||||
import com.keylesspalace.tusky.util.isLessThan
|
||||
import com.keylesspalace.tusky.util.isLessThanOrEqual
|
||||
|
@ -249,6 +249,10 @@ class NetworkTimelineViewModel @Inject constructor(
|
|||
currentSource?.invalidate()
|
||||
}
|
||||
|
||||
override suspend fun invalidate() {
|
||||
currentSource?.invalidate()
|
||||
}
|
||||
|
||||
@Throws(IOException::class, HttpException::class)
|
||||
suspend fun fetchStatusesForKind(
|
||||
fromId: String?,
|
||||
|
|
|
@ -39,8 +39,8 @@ import com.keylesspalace.tusky.entity.Filter
|
|||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.network.FilterModel
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
@ -81,6 +81,7 @@ abstract class TimelineViewModel(
|
|||
this.tags = tags
|
||||
|
||||
if (kind == Kind.HOME) {
|
||||
// Note the variable is "true if filter" but the underlying preference/settings text is "true if show"
|
||||
filterRemoveReplies =
|
||||
!sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true)
|
||||
filterRemoveReblogs =
|
||||
|
@ -172,6 +173,9 @@ abstract class TimelineViewModel(
|
|||
|
||||
abstract fun fullReload()
|
||||
|
||||
/** Triggered when currently displayed data must be reloaded. */
|
||||
protected abstract suspend fun invalidate()
|
||||
|
||||
protected fun shouldFilterStatus(statusViewData: StatusViewData): Boolean {
|
||||
val status = statusViewData.asStatusOrNull()?.status ?: return false
|
||||
return status.inReplyToId != null && filterRemoveReplies ||
|
||||
|
@ -287,6 +291,9 @@ abstract class TimelineViewModel(
|
|||
filterContextMatchesKind(kind, it.context)
|
||||
}
|
||||
)
|
||||
// After the filters are loaded we need to reload displayed content to apply them.
|
||||
// It can happen during the usage or at startup, when we get statuses before filters.
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -37,6 +37,8 @@ data class AccountEntity(
|
|||
@field:PrimaryKey(autoGenerate = true) var id: Long,
|
||||
val domain: String,
|
||||
var accessToken: String,
|
||||
var clientId: String?, // nullable for backward compatibility
|
||||
var clientSecret: String?, // nullable for backward compatibility
|
||||
var isActive: Boolean,
|
||||
var accountId: String = "",
|
||||
var username: String = "",
|
||||
|
@ -64,7 +66,15 @@ data class AccountEntity(
|
|||
var activeNotifications: String = "[]",
|
||||
var emojis: List<Emoji> = emptyList(),
|
||||
var tabPreferences: List<TabData> = defaultTabs(),
|
||||
var notificationsFilter: String = "[\"follow_request\"]"
|
||||
var notificationsFilter: String = "[\"follow_request\"]",
|
||||
// Scope cannot be changed without re-login, so store it in case
|
||||
// the scope needs to be changed in the future
|
||||
var oauthScopes: String = "",
|
||||
var unifiedPushUrl: String = "",
|
||||
var pushPubKey: String = "",
|
||||
var pushPrivKey: String = "",
|
||||
var pushAuth: String = "",
|
||||
var pushServerKey: String = "",
|
||||
) {
|
||||
|
||||
val identifier: String
|
||||
|
@ -73,6 +83,15 @@ data class AccountEntity(
|
|||
val fullName: String
|
||||
get() = "@$username@$domain"
|
||||
|
||||
fun logout() {
|
||||
// deleting credentials so they cannot be used again
|
||||
accessToken = ""
|
||||
clientId = null
|
||||
clientSecret = null
|
||||
}
|
||||
|
||||
fun isLoggedIn() = accessToken.isNotEmpty()
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
|
|
@ -48,13 +48,22 @@ class AccountManager @Inject constructor(db: AppDatabase) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Adds a new empty account and makes it the active account.
|
||||
* More account information has to be added later with [updateActiveAccount]
|
||||
* or the account wont be saved to the database.
|
||||
* Adds a new account and makes it the active account.
|
||||
* @param accessToken the access token for the new account
|
||||
* @param domain the domain of the accounts Mastodon instance
|
||||
* @param clientId the oauth client id used to sign in the account
|
||||
* @param clientSecret the oauth client secret used to sign in the account
|
||||
* @param oauthScopes the oauth scopes granted to the account
|
||||
* @param newAccount the [Account] as returned by the Mastodon Api
|
||||
*/
|
||||
fun addAccount(accessToken: String, domain: String) {
|
||||
fun addAccount(
|
||||
accessToken: String,
|
||||
domain: String,
|
||||
clientId: String,
|
||||
clientSecret: String,
|
||||
oauthScopes: String,
|
||||
newAccount: Account
|
||||
) {
|
||||
|
||||
activeAccount?.let {
|
||||
it.isActive = false
|
||||
|
@ -62,10 +71,35 @@ class AccountManager @Inject constructor(db: AppDatabase) {
|
|||
|
||||
accountDao.insertOrReplace(it)
|
||||
}
|
||||
// check if this is a relogin with an existing account, if yes update it, otherwise create a new one
|
||||
val existingAccountIndex = accounts.indexOfFirst { account ->
|
||||
domain == account.domain && newAccount.id == account.accountId
|
||||
}
|
||||
val newAccountEntity = if (existingAccountIndex != -1) {
|
||||
accounts[existingAccountIndex].copy(
|
||||
accessToken = accessToken,
|
||||
clientId = clientId,
|
||||
clientSecret = clientSecret,
|
||||
oauthScopes = oauthScopes,
|
||||
isActive = true
|
||||
).also { accounts[existingAccountIndex] = it }
|
||||
} else {
|
||||
val maxAccountId = accounts.maxByOrNull { it.id }?.id ?: 0
|
||||
val newAccountId = maxAccountId + 1
|
||||
AccountEntity(
|
||||
id = newAccountId,
|
||||
domain = domain.lowercase(Locale.ROOT),
|
||||
accessToken = accessToken,
|
||||
clientId = clientId,
|
||||
clientSecret = clientSecret,
|
||||
oauthScopes = oauthScopes,
|
||||
isActive = true,
|
||||
accountId = newAccount.id
|
||||
).also { accounts.add(it) }
|
||||
}
|
||||
|
||||
val maxAccountId = accounts.maxByOrNull { it.id }?.id ?: 0
|
||||
val newAccountId = maxAccountId + 1
|
||||
activeAccount = AccountEntity(id = newAccountId, domain = domain.lowercase(Locale.ROOT), accessToken = accessToken, isActive = true)
|
||||
activeAccount = newAccountEntity
|
||||
updateActiveAccount(newAccount)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -86,11 +120,12 @@ class AccountManager @Inject constructor(db: AppDatabase) {
|
|||
*/
|
||||
fun logActiveAccountOut(): AccountEntity? {
|
||||
|
||||
if (activeAccount == null) {
|
||||
return null
|
||||
} else {
|
||||
accounts.remove(activeAccount!!)
|
||||
accountDao.delete(activeAccount!!)
|
||||
return activeAccount?.let { account ->
|
||||
|
||||
account.logout()
|
||||
|
||||
accounts.remove(account)
|
||||
accountDao.delete(account)
|
||||
|
||||
if (accounts.size > 0) {
|
||||
accounts[0].isActive = true
|
||||
|
@ -100,7 +135,7 @@ class AccountManager @Inject constructor(db: AppDatabase) {
|
|||
} else {
|
||||
activeAccount = null
|
||||
}
|
||||
return activeAccount
|
||||
activeAccount
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -120,17 +155,7 @@ class AccountManager @Inject constructor(db: AppDatabase) {
|
|||
it.emojis = account.emojis ?: emptyList()
|
||||
|
||||
Log.d(TAG, "updateActiveAccount: saving account with id " + it.id)
|
||||
it.id = accountDao.insertOrReplace(it)
|
||||
|
||||
val accountIndex = accounts.indexOf(it)
|
||||
|
||||
if (accountIndex != -1) {
|
||||
// in case the user was already logged in with this account, remove the old information
|
||||
accounts.removeAt(accountIndex)
|
||||
accounts.add(accountIndex, it)
|
||||
} else {
|
||||
accounts.add(it)
|
||||
}
|
||||
accountDao.insertOrReplace(it)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -189,4 +214,15 @@ class AccountManager @Inject constructor(db: AppDatabase) {
|
|||
id == accountId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an account by its string identifier
|
||||
* @param identifier the string identifier of the account
|
||||
* @return the requested account or null if it was not found
|
||||
*/
|
||||
fun getAccountByIdentifier(identifier: String): AccountEntity? {
|
||||
return accounts.find {
|
||||
identifier == it.identifier
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ import java.io.File;
|
|||
*/
|
||||
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
|
||||
TimelineAccountEntity.class, ConversationEntity.class
|
||||
}, version = 35)
|
||||
}, version = 39)
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
|
||||
public abstract AccountDao accountDao();
|
||||
|
@ -541,4 +541,44 @@ public abstract class AppDatabase extends RoomDatabase {
|
|||
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `card` TEXT");
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_35_36 = new Migration(35, 36) {
|
||||
@Override
|
||||
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `oauthScopes` TEXT NOT NULL DEFAULT ''");
|
||||
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `unifiedPushUrl` TEXT NOT NULL DEFAULT ''");
|
||||
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushPubKey` TEXT NOT NULL DEFAULT ''");
|
||||
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushPrivKey` TEXT NOT NULL DEFAULT ''");
|
||||
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushAuth` TEXT NOT NULL DEFAULT ''");
|
||||
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushServerKey` TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_36_37 = new Migration(36, 37) {
|
||||
@Override
|
||||
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `repliesCount` INTEGER NOT NULL DEFAULT 0");
|
||||
database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_repliesCount` INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_37_38 = new Migration(37, 38) {
|
||||
@Override
|
||||
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||
// database needs to be cleaned because the ConversationAccountEntity got a new attribute
|
||||
database.execSQL("DELETE FROM `ConversationEntity`");
|
||||
database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `order` INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
// timestamps are now serialized differently so all cache tables that contain them need to be cleaned
|
||||
database.execSQL("DELETE FROM `TimelineStatusEntity`");
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_38_39 = new Migration(38, 39) {
|
||||
@Override
|
||||
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `clientId` TEXT");
|
||||
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `clientSecret` TEXT");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -28,14 +28,14 @@ interface ConversationsDao {
|
|||
suspend fun insert(conversations: List<ConversationEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(conversation: ConversationEntity): Long
|
||||
suspend fun insert(conversation: ConversationEntity)
|
||||
|
||||
@Query("DELETE FROM ConversationEntity WHERE id = :id AND accountId = :accountId")
|
||||
suspend fun delete(id: String, accountId: Long): Int
|
||||
suspend fun delete(id: String, accountId: Long)
|
||||
|
||||
@Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC")
|
||||
@Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY `order` ASC")
|
||||
fun conversationsForAccount(accountId: Long): PagingSource<Int, ConversationEntity>
|
||||
|
||||
@Query("DELETE FROM ConversationEntity WHERE accountId = :accountId")
|
||||
fun deleteForAccount(accountId: Long)
|
||||
suspend fun deleteForAccount(accountId: Long)
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import androidx.room.Dao
|
|||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||
|
||||
@Dao
|
||||
interface InstanceDao {
|
||||
|
@ -29,9 +30,11 @@ interface InstanceDao {
|
|||
@Insert(onConflict = OnConflictStrategy.REPLACE, entity = InstanceEntity::class)
|
||||
suspend fun insertOrReplace(emojis: EmojisEntity)
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1")
|
||||
suspend fun getInstanceInfo(instance: String): InstanceInfoEntity?
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1")
|
||||
suspend fun getEmojiInfo(instance: String): EmojisEntity?
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ abstract class TimelineDao {
|
|||
"""
|
||||
SELECT s.serverId, s.url, s.timelineUserId,
|
||||
s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt,
|
||||
s.emojis, s.reblogsCount, s.favouritesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive,
|
||||
s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive,
|
||||
s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId,
|
||||
s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned,
|
||||
a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId',
|
||||
|
@ -197,4 +197,7 @@ AND timelineUserId = :accountId
|
|||
*/
|
||||
@Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1")
|
||||
abstract suspend fun getNextPlaceholderIdAfter(accountId: Long, serverId: String): String?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM TimelineStatusEntity WHERE timelineUserId = :accountId")
|
||||
abstract suspend fun getStatusCount(accountId: Long): Int
|
||||
}
|
||||
|
|
|
@ -61,6 +61,7 @@ data class TimelineStatusEntity(
|
|||
val emojis: String?,
|
||||
val reblogsCount: Int,
|
||||
val favouritesCount: Int,
|
||||
val repliesCount: Int,
|
||||
val reblogged: Boolean,
|
||||
val bookmarked: Boolean,
|
||||
val favourited: Boolean,
|
||||
|
|
|
@ -64,6 +64,8 @@ class AppModule {
|
|||
AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29,
|
||||
AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32,
|
||||
AppDatabase.MIGRATION_32_33, AppDatabase.MIGRATION_33_34, AppDatabase.MIGRATION_34_35,
|
||||
AppDatabase.MIGRATION_35_36, AppDatabase.MIGRATION_36_37, AppDatabase.MIGRATION_37_38,
|
||||
AppDatabase.MIGRATION_38_39
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
|
|
@ -16,8 +16,10 @@
|
|||
|
||||
package com.keylesspalace.tusky.di
|
||||
|
||||
import com.keylesspalace.tusky.receiver.NotificationBlockStateBroadcastReceiver
|
||||
import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver
|
||||
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver
|
||||
import com.keylesspalace.tusky.receiver.UnifiedPushBroadcastReceiver
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
|
||||
|
@ -28,4 +30,10 @@ abstract class BroadcastReceiverModule {
|
|||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeNotificationClearBroadcastReceiver(): NotificationClearBroadcastReceiver
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeUnifiedPushBroadcastReceiver(): UnifiedPushBroadcastReceiver
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeNotificationBlockStateBroadcastReceiver(): NotificationBlockStateBroadcastReceiver
|
||||
}
|
||||
|
|
|
@ -18,10 +18,12 @@ package com.keylesspalace.tusky.di
|
|||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import at.connyduck.calladapter.kotlinresult.KotlinResultCallAdapterFactory
|
||||
import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.keylesspalace.tusky.BuildConfig
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.json.Rfc3339DateJsonAdapter
|
||||
import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.MediaUploadApi
|
||||
|
@ -38,6 +40,7 @@ import retrofit2.converter.gson.GsonConverterFactory
|
|||
import retrofit2.create
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.util.Date
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
@ -50,7 +53,9 @@ class NetworkModule {
|
|||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesGson() = Gson()
|
||||
fun providesGson(): Gson = GsonBuilder()
|
||||
.registerTypeAdapter(Date::class.java, Rfc3339DateJsonAdapter())
|
||||
.create()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
|
@ -106,7 +111,7 @@ class NetworkModule {
|
|||
.client(httpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
|
||||
.addCallAdapterFactory(KotlinResultCallAdapterFactory.create())
|
||||
.addCallAdapterFactory(NetworkResultCallAdapterFactory.create())
|
||||
.build()
|
||||
}
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ data class Account(
|
|||
@SerializedName("username") val localUsername: String,
|
||||
@SerializedName("acct") val username: String,
|
||||
@SerializedName("display_name") val displayName: String?, // should never be null per Api definition, but some servers break the contract
|
||||
@SerializedName("created_at") val createdAt: Date,
|
||||
val note: String,
|
||||
val url: String,
|
||||
val avatar: String,
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
/* Copyright 2022 Tusky contributors
|
||||
*
|
||||
* 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.entity
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class NotificationSubscribeResult(
|
||||
val id: Int,
|
||||
val endpoint: String,
|
||||
@SerializedName("server_key") val serverKey: String,
|
||||
)
|
|
@ -34,6 +34,7 @@ data class Status(
|
|||
val emojis: List<Emoji>,
|
||||
@SerializedName("reblogs_count") val reblogsCount: Int,
|
||||
@SerializedName("favourites_count") val favouritesCount: Int,
|
||||
@SerializedName("replies_count") val repliesCount: Int,
|
||||
var reblogged: Boolean,
|
||||
var favourited: Boolean,
|
||||
var bookmarked: Boolean,
|
||||
|
|
|
@ -538,7 +538,7 @@ public class NotificationsFragment extends SFragment implements
|
|||
|
||||
@Override
|
||||
public void onContentCollapsedChange(boolean isCollapsed, int position) {
|
||||
updateViewDataAt(position, (vd) -> vd.copyWIthCollapsed(isCollapsed));
|
||||
updateViewDataAt(position, (vd) -> vd.copyWithCollapsed(isCollapsed));
|
||||
;
|
||||
}
|
||||
|
||||
|
@ -963,10 +963,10 @@ public class NotificationsFragment extends SFragment implements
|
|||
if (notifications.size() == 0 && adapter.getItemCount() == 0) {
|
||||
this.statusView.setVisibility(View.VISIBLE);
|
||||
this.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null);
|
||||
} else {
|
||||
swipeRefreshLayout.setEnabled(true);
|
||||
}
|
||||
|
||||
updateFilterVisibility();
|
||||
swipeRefreshLayout.setEnabled(true);
|
||||
swipeRefreshLayout.setRefreshing(false);
|
||||
progressBar.setVisibility(View.GONE);
|
||||
}
|
||||
|
@ -1231,7 +1231,7 @@ public class NotificationsFragment extends SFragment implements
|
|||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity());
|
||||
boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false);
|
||||
if (!useAbsoluteTime) {
|
||||
Observable.interval(1, TimeUnit.MINUTES)
|
||||
Observable.interval(0, 1, TimeUnit.MINUTES)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.to(autoDisposable(from(this, Lifecycle.Event.ON_PAUSE)))
|
||||
.subscribe(
|
||||
|
|
|
@ -56,7 +56,7 @@ import com.keylesspalace.tusky.di.Injectable;
|
|||
import com.keylesspalace.tusky.entity.Attachment;
|
||||
import com.keylesspalace.tusky.entity.Status;
|
||||
import com.keylesspalace.tusky.network.MastodonApi;
|
||||
import com.keylesspalace.tusky.network.TimelineCases;
|
||||
import com.keylesspalace.tusky.usecase.TimelineCases;
|
||||
import com.keylesspalace.tusky.util.LinkHelper;
|
||||
import com.keylesspalace.tusky.util.StatusParsingHelper;
|
||||
import com.keylesspalace.tusky.view.MuteAccountDialog;
|
||||
|
|
|
@ -387,7 +387,7 @@ public final class ViewThreadFragment extends SFragment implements
|
|||
public void onContentCollapsedChange(boolean isCollapsed, int position) {
|
||||
adapter.setItem(
|
||||
position,
|
||||
statuses.getPairedItem(position).copyWIthCollapsed(isCollapsed),
|
||||
statuses.getPairedItem(position).copyWithCollapsed(isCollapsed),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
|
268
app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt
Normal file
268
app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt
Normal file
|
@ -0,0 +1,268 @@
|
|||
package com.keylesspalace.tusky.json
|
||||
|
||||
/*
|
||||
* Copyright (C) 2011 FasterXML, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import com.google.gson.JsonParseException
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.GregorianCalendar
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
/*
|
||||
* Jackson’s date formatter, pruned to Moshi's needs. Forked from this file:
|
||||
* https://github.com/FasterXML/jackson-databind/blob/67ebf7305f492285a8f9f4de31545f5f16fc7c3a/src/main/java/com/fasterxml/jackson/databind/util/ISO8601Utils.java
|
||||
*
|
||||
* Utilities methods for manipulating dates in iso8601 format. This is much much faster and GC
|
||||
* friendly than using SimpleDateFormat so highly suitable if you (un)serialize lots of date
|
||||
* objects.
|
||||
*
|
||||
* Supported parse format:
|
||||
* `[yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:]mm]]`
|
||||
*
|
||||
* @see [this specification](http://www.w3.org/TR/NOTE-datetime)
|
||||
*/
|
||||
|
||||
/** ID to represent the 'GMT' string */
|
||||
private const val GMT_ID = "GMT"
|
||||
|
||||
/** The GMT timezone, prefetched to avoid more lookups. */
|
||||
private val TIMEZONE_Z: TimeZone = TimeZone.getTimeZone(GMT_ID)
|
||||
|
||||
/** Returns `date` formatted as yyyy-MM-ddThh:mm:ss.sssZ */
|
||||
internal fun Date.formatIsoDate(): String {
|
||||
val calendar: Calendar = GregorianCalendar(TIMEZONE_Z, Locale.US)
|
||||
calendar.time = this
|
||||
|
||||
// estimate capacity of buffer as close as we can (yeah, that's pedantic ;)
|
||||
val capacity = "yyyy-MM-ddThh:mm:ss.sssZ".length
|
||||
val formatted = StringBuilder(capacity)
|
||||
padInt(formatted, calendar[Calendar.YEAR], "yyyy".length)
|
||||
formatted.append('-')
|
||||
padInt(formatted, calendar[Calendar.MONTH] + 1, "MM".length)
|
||||
formatted.append('-')
|
||||
padInt(formatted, calendar[Calendar.DAY_OF_MONTH], "dd".length)
|
||||
formatted.append('T')
|
||||
padInt(formatted, calendar[Calendar.HOUR_OF_DAY], "hh".length)
|
||||
formatted.append(':')
|
||||
padInt(formatted, calendar[Calendar.MINUTE], "mm".length)
|
||||
formatted.append(':')
|
||||
padInt(formatted, calendar[Calendar.SECOND], "ss".length)
|
||||
formatted.append('.')
|
||||
padInt(formatted, calendar[Calendar.MILLISECOND], "sss".length)
|
||||
formatted.append('Z')
|
||||
return formatted.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a date from ISO-8601 formatted string. It expects a format
|
||||
* `[yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh:mm]]`
|
||||
*
|
||||
* @receiver ISO string to parse in the appropriate format.
|
||||
* @return the parsed date
|
||||
*/
|
||||
internal fun String.parseIsoDate(): Date {
|
||||
return try {
|
||||
var offset = 0
|
||||
|
||||
// extract year
|
||||
val year = parseInt(this, offset, 4.let { offset += it; offset })
|
||||
if (checkOffset(this, offset, '-')) {
|
||||
offset += 1
|
||||
}
|
||||
|
||||
// extract month
|
||||
val month = parseInt(this, offset, 2.let { offset += it; offset })
|
||||
if (checkOffset(this, offset, '-')) {
|
||||
offset += 1
|
||||
}
|
||||
|
||||
// extract day
|
||||
val day = parseInt(this, offset, 2.let { offset += it; offset })
|
||||
// default time value
|
||||
var hour = 0
|
||||
var minutes = 0
|
||||
var seconds = 0
|
||||
// always use 0 otherwise returned date will include millis of current time
|
||||
var milliseconds = 0
|
||||
|
||||
// if the value has no time component (and no time zone), we are done
|
||||
val hasT = checkOffset(this, offset, 'T')
|
||||
if (!hasT && this.length <= offset) {
|
||||
return GregorianCalendar(year, month - 1, day).time
|
||||
}
|
||||
if (hasT) {
|
||||
|
||||
// extract hours, minutes, seconds and milliseconds
|
||||
hour = parseInt(this, 1.let { offset += it; offset }, 2.let { offset += it; offset })
|
||||
if (checkOffset(this, offset, ':')) {
|
||||
offset += 1
|
||||
}
|
||||
minutes = parseInt(this, offset, 2.let { offset += it; offset })
|
||||
if (checkOffset(this, offset, ':')) {
|
||||
offset += 1
|
||||
}
|
||||
// second and milliseconds can be optional
|
||||
if (this.length > offset) {
|
||||
val c = this[offset]
|
||||
if (c != 'Z' && c != '+' && c != '-') {
|
||||
seconds = parseInt(this, offset, 2.let { offset += it; offset })
|
||||
if (seconds in 60..62) seconds = 59 // truncate up to 3 leap seconds
|
||||
// milliseconds can be optional in the format
|
||||
if (checkOffset(this, offset, '.')) {
|
||||
offset += 1
|
||||
val endOffset = indexOfNonDigit(this, offset + 1) // assume at least one digit
|
||||
val parseEndOffset = min(endOffset, offset + 3) // parse up to 3 digits
|
||||
val fraction = parseInt(this, offset, parseEndOffset)
|
||||
milliseconds =
|
||||
(10.0.pow((3 - (parseEndOffset - offset)).toDouble()) * fraction).toInt()
|
||||
offset = endOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extract timezone
|
||||
require(this.length > offset) { "No time zone indicator" }
|
||||
val timezone: TimeZone
|
||||
val timezoneIndicator = this[offset]
|
||||
if (timezoneIndicator == 'Z') {
|
||||
timezone = TIMEZONE_Z
|
||||
} else if (timezoneIndicator == '+' || timezoneIndicator == '-') {
|
||||
val timezoneOffset = this.substring(offset)
|
||||
// 18-Jun-2015, tatu: Minor simplification, skip offset of "+0000"/"+00:00"
|
||||
if ("+0000" == timezoneOffset || "+00:00" == timezoneOffset) {
|
||||
timezone = TIMEZONE_Z
|
||||
} else {
|
||||
// 18-Jun-2015, tatu: Looks like offsets only work from GMT, not UTC...
|
||||
// not sure why, but it is what it is.
|
||||
val timezoneId = GMT_ID + timezoneOffset
|
||||
timezone = TimeZone.getTimeZone(timezoneId)
|
||||
val act = timezone.id
|
||||
if (act != timezoneId) {
|
||||
/*
|
||||
* 22-Jan-2015, tatu: Looks like canonical version has colons, but we may be given
|
||||
* one without. If so, don't sweat.
|
||||
* Yes, very inefficient. Hopefully not hit often.
|
||||
* If it becomes a perf problem, add 'loose' comparison instead.
|
||||
*/
|
||||
val cleaned = act.replace(":", "")
|
||||
if (cleaned != timezoneId) {
|
||||
throw IndexOutOfBoundsException(
|
||||
"Mismatching time zone indicator: $timezoneId given, resolves to ${timezone.id}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw IndexOutOfBoundsException(
|
||||
"Invalid time zone indicator '$timezoneIndicator'"
|
||||
)
|
||||
}
|
||||
val calendar: Calendar = GregorianCalendar(timezone)
|
||||
calendar.isLenient = false
|
||||
calendar[Calendar.YEAR] = year
|
||||
calendar[Calendar.MONTH] = month - 1
|
||||
calendar[Calendar.DAY_OF_MONTH] = day
|
||||
calendar[Calendar.HOUR_OF_DAY] = hour
|
||||
calendar[Calendar.MINUTE] = minutes
|
||||
calendar[Calendar.SECOND] = seconds
|
||||
calendar[Calendar.MILLISECOND] = milliseconds
|
||||
calendar.time
|
||||
// If we get a ParseException it'll already have the right message/offset.
|
||||
// Other exception types can convert here.
|
||||
} catch (e: IndexOutOfBoundsException) {
|
||||
throw JsonParseException("Not an RFC 3339 date: $this", e)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw JsonParseException("Not an RFC 3339 date: $this", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the expected character exist at the given offset in the value.
|
||||
*
|
||||
* @param value the string to check at the specified offset
|
||||
* @param offset the offset to look for the expected character
|
||||
* @param expected the expected character
|
||||
* @return true if the expected character exist at the given offset
|
||||
*/
|
||||
private fun checkOffset(value: String, offset: Int, expected: Char): Boolean {
|
||||
return offset < value.length && value[offset] == expected
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an integer located between 2 given offsets in a string
|
||||
*
|
||||
* @param value the string to parse
|
||||
* @param beginIndex the start index for the integer in the string
|
||||
* @param endIndex the end index for the integer in the string
|
||||
* @return the int
|
||||
* @throws NumberFormatException if the value is not a number
|
||||
*/
|
||||
private fun parseInt(value: String, beginIndex: Int, endIndex: Int): Int {
|
||||
if (beginIndex < 0 || endIndex > value.length || beginIndex > endIndex) {
|
||||
throw NumberFormatException(value)
|
||||
}
|
||||
// use same logic as in Integer.parseInt() but less generic we're not supporting negative values
|
||||
var i = beginIndex
|
||||
var result = 0
|
||||
var digit: Int
|
||||
if (i < endIndex) {
|
||||
digit = Character.digit(value[i++], 10)
|
||||
if (digit < 0) {
|
||||
throw NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex))
|
||||
}
|
||||
result = -digit
|
||||
}
|
||||
while (i < endIndex) {
|
||||
digit = Character.digit(value[i++], 10)
|
||||
if (digit < 0) {
|
||||
throw NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex))
|
||||
}
|
||||
result *= 10
|
||||
result -= digit
|
||||
}
|
||||
return -result
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero pad a number to a specified length
|
||||
*
|
||||
* @param buffer buffer to use for padding
|
||||
* @param value the integer value to pad if necessary.
|
||||
* @param length the length of the string we should zero pad
|
||||
*/
|
||||
private fun padInt(buffer: StringBuilder, value: Int, length: Int) {
|
||||
val strValue = value.toString()
|
||||
for (i in length - strValue.length downTo 1) {
|
||||
buffer.append('0')
|
||||
}
|
||||
buffer.append(strValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the first character in the string that is not a digit, starting at offset.
|
||||
*/
|
||||
private fun indexOfNonDigit(string: String, offset: Int): Int {
|
||||
for (i in offset until string.length) {
|
||||
val c = string[i]
|
||||
if (c < '0' || c > '9') return i
|
||||
}
|
||||
return string.length
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
// https://github.com/google/gson/blob/master/extras/src/main/java/com/google/gson/typeadapters/UtcDateTypeAdapter.java
|
||||
/*
|
||||
* Copyright (C) 2011 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.keylesspalace.tusky.json
|
||||
|
||||
import com.google.gson.TypeAdapter
|
||||
import com.google.gson.stream.JsonReader
|
||||
import com.google.gson.stream.JsonToken
|
||||
import com.google.gson.stream.JsonWriter
|
||||
import java.io.IOException
|
||||
import java.util.Date
|
||||
|
||||
class Rfc3339DateJsonAdapter : TypeAdapter<Date?>() {
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun write(writer: JsonWriter, date: Date?) {
|
||||
if (date == null) {
|
||||
writer.nullValue()
|
||||
} else {
|
||||
writer.value(date.formatIsoDate())
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(reader: JsonReader): Date? {
|
||||
return when (reader.peek()) {
|
||||
JsonToken.NULL -> {
|
||||
reader.nextNull()
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
reader.nextString().parseIsoDate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,76 +0,0 @@
|
|||
/* Copyright 2018 charlag
|
||||
*
|
||||
* 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.network;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.keylesspalace.tusky.db.AccountEntity;
|
||||
import com.keylesspalace.tusky.db.AccountManager;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.Interceptor;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
/**
|
||||
* Created by charlag on 31/10/17.
|
||||
*/
|
||||
|
||||
public final class InstanceSwitchAuthInterceptor implements Interceptor {
|
||||
private AccountManager accountManager;
|
||||
|
||||
public InstanceSwitchAuthInterceptor(AccountManager accountManager) {
|
||||
this.accountManager = accountManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response intercept(@NonNull Chain chain) throws IOException {
|
||||
|
||||
Request originalRequest = chain.request();
|
||||
|
||||
// only switch domains if the request comes from retrofit
|
||||
if (originalRequest.url().host().equals(MastodonApi.PLACEHOLDER_DOMAIN)) {
|
||||
AccountEntity currentAccount = accountManager.getActiveAccount();
|
||||
|
||||
Request.Builder builder = originalRequest.newBuilder();
|
||||
|
||||
String instanceHeader = originalRequest.header(MastodonApi.DOMAIN_HEADER);
|
||||
if (instanceHeader != null) {
|
||||
// use domain explicitly specified in custom header
|
||||
builder.url(swapHost(originalRequest.url(), instanceHeader));
|
||||
builder.removeHeader(MastodonApi.DOMAIN_HEADER);
|
||||
} else if (currentAccount != null) {
|
||||
//use domain of current account
|
||||
builder.url(swapHost(originalRequest.url(), currentAccount.getDomain()))
|
||||
.header("Authorization",
|
||||
String.format("Bearer %s", currentAccount.getAccessToken()));
|
||||
}
|
||||
Request newRequest = builder.build();
|
||||
|
||||
return chain.proceed(newRequest);
|
||||
|
||||
} else {
|
||||
return chain.proceed(originalRequest);
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static HttpUrl swapHost(@NonNull HttpUrl url, @NonNull String host) {
|
||||
return url.newBuilder().host(host).build();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
/* Copyright 2022 Tusky Contributors
|
||||
*
|
||||
* 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.network
|
||||
|
||||
import android.util.Log
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import java.io.IOException
|
||||
|
||||
class InstanceSwitchAuthInterceptor(private val accountManager: AccountManager) : Interceptor {
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest: Request = chain.request()
|
||||
|
||||
// only switch domains if the request comes from retrofit
|
||||
return if (originalRequest.url.host == MastodonApi.PLACEHOLDER_DOMAIN) {
|
||||
|
||||
val builder: Request.Builder = originalRequest.newBuilder()
|
||||
val instanceHeader = originalRequest.header(MastodonApi.DOMAIN_HEADER)
|
||||
|
||||
if (instanceHeader != null) {
|
||||
// use domain explicitly specified in custom header
|
||||
builder.url(swapHost(originalRequest.url, instanceHeader))
|
||||
builder.removeHeader(MastodonApi.DOMAIN_HEADER)
|
||||
} else {
|
||||
val currentAccount = accountManager.activeAccount
|
||||
|
||||
if (currentAccount != null) {
|
||||
val accessToken = currentAccount.accessToken
|
||||
if (accessToken.isNotEmpty()) {
|
||||
// use domain of current account
|
||||
builder.url(swapHost(originalRequest.url, currentAccount.domain))
|
||||
.header("Authorization", "Bearer %s".format(accessToken))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val newRequest: Request = builder.build()
|
||||
|
||||
if (MastodonApi.PLACEHOLDER_DOMAIN == newRequest.url.host) {
|
||||
Log.w("ISAInterceptor", "no user logged in or no domain header specified - can't make request to " + newRequest.url)
|
||||
return Response.Builder()
|
||||
.code(400)
|
||||
.message("Bad Request")
|
||||
.protocol(Protocol.HTTP_2)
|
||||
.body("".toResponseBody("text/plain".toMediaType()))
|
||||
.request(chain.request())
|
||||
.build()
|
||||
}
|
||||
|
||||
chain.proceed(newRequest)
|
||||
} else {
|
||||
chain.proceed(originalRequest)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun swapHost(url: HttpUrl, host: String): HttpUrl {
|
||||
return url.newBuilder().host(host).build()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
package com.keylesspalace.tusky.network
|
||||
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import com.keylesspalace.tusky.entity.AccessToken
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Announcement
|
||||
|
@ -30,6 +31,7 @@ import com.keylesspalace.tusky.entity.MastoList
|
|||
import com.keylesspalace.tusky.entity.MediaUploadResult
|
||||
import com.keylesspalace.tusky.entity.NewStatus
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.NotificationSubscribeResult
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.Relationship
|
||||
import com.keylesspalace.tusky.entity.ScheduledStatus
|
||||
|
@ -37,7 +39,6 @@ import com.keylesspalace.tusky.entity.SearchResult
|
|||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.entity.StatusContext
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
|
@ -47,6 +48,7 @@ import retrofit2.Response
|
|||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.Field
|
||||
import retrofit2.http.FieldMap
|
||||
import retrofit2.http.FormUrlEncoded
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.HTTP
|
||||
|
@ -72,14 +74,11 @@ interface MastodonApi {
|
|||
const val PLACEHOLDER_DOMAIN = "dummy.placeholder"
|
||||
}
|
||||
|
||||
@GET("/api/v1/lists")
|
||||
fun getLists(): Single<List<MastoList>>
|
||||
|
||||
@GET("/api/v1/custom_emojis")
|
||||
suspend fun getCustomEmojis(): Result<List<Emoji>>
|
||||
suspend fun getCustomEmojis(): NetworkResult<List<Emoji>>
|
||||
|
||||
@GET("api/v1/instance")
|
||||
suspend fun getInstance(): Result<Instance>
|
||||
suspend fun getInstance(): NetworkResult<Instance>
|
||||
|
||||
@GET("api/v1/filters")
|
||||
fun getFilters(): Single<List<Filter>>
|
||||
|
@ -147,7 +146,7 @@ interface MastodonApi {
|
|||
suspend fun updateMedia(
|
||||
@Path("mediaId") mediaId: String,
|
||||
@Field("description") description: String
|
||||
): Result<Attachment>
|
||||
): NetworkResult<Attachment>
|
||||
|
||||
@GET("api/v1/media/{mediaId}")
|
||||
suspend fun getMedia(
|
||||
|
@ -160,7 +159,7 @@ interface MastodonApi {
|
|||
@Header(DOMAIN_HEADER) domain: String,
|
||||
@Header("Idempotency-Key") idempotencyKey: String,
|
||||
@Body status: NewStatus
|
||||
): Result<Status>
|
||||
): NetworkResult<Status>
|
||||
|
||||
@GET("api/v1/statuses/{id}")
|
||||
fun status(
|
||||
|
@ -248,10 +247,13 @@ interface MastodonApi {
|
|||
@DELETE("api/v1/scheduled_statuses/{id}")
|
||||
suspend fun deleteScheduledStatus(
|
||||
@Path("id") scheduledStatusId: String
|
||||
): Result<ResponseBody>
|
||||
): NetworkResult<ResponseBody>
|
||||
|
||||
@GET("api/v1/accounts/verify_credentials")
|
||||
suspend fun accountVerifyCredentials(): Result<Account>
|
||||
suspend fun accountVerifyCredentials(
|
||||
@Header(DOMAIN_HEADER) domain: String? = null,
|
||||
@Header("Authorization") auth: String? = null,
|
||||
): NetworkResult<Account>
|
||||
|
||||
@FormUrlEncoded
|
||||
@PATCH("api/v1/accounts/update_credentials")
|
||||
|
@ -276,15 +278,23 @@ interface MastodonApi {
|
|||
@Part(value = "fields_attributes[2][value]") fieldValue2: RequestBody?,
|
||||
@Part(value = "fields_attributes[3][name]") fieldName3: RequestBody?,
|
||||
@Part(value = "fields_attributes[3][value]") fieldValue3: RequestBody?
|
||||
): Result<Account>
|
||||
): NetworkResult<Account>
|
||||
|
||||
@GET("api/v1/accounts/search")
|
||||
fun searchAccounts(
|
||||
suspend fun searchAccounts(
|
||||
@Query("q") query: String,
|
||||
@Query("resolve") resolve: Boolean? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("following") following: Boolean? = null
|
||||
): Single<List<TimelineAccount>>
|
||||
): NetworkResult<List<TimelineAccount>>
|
||||
|
||||
@GET("api/v1/accounts/search")
|
||||
fun searchAccountsSync(
|
||||
@Query("q") query: String,
|
||||
@Query("resolve") resolve: Boolean? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("following") following: Boolean? = null
|
||||
): NetworkResult<List<TimelineAccount>>
|
||||
|
||||
@GET("api/v1/accounts/{id}")
|
||||
fun account(
|
||||
|
@ -439,7 +449,7 @@ interface MastodonApi {
|
|||
@Field("redirect_uris") redirectUris: String,
|
||||
@Field("scopes") scopes: String,
|
||||
@Field("website") website: String
|
||||
): Result<AppCredentials>
|
||||
): NetworkResult<AppCredentials>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("oauth/token")
|
||||
|
@ -450,52 +460,63 @@ interface MastodonApi {
|
|||
@Field("redirect_uri") redirectUri: String,
|
||||
@Field("code") code: String,
|
||||
@Field("grant_type") grantType: String
|
||||
): Result<AccessToken>
|
||||
): NetworkResult<AccessToken>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("oauth/revoke")
|
||||
suspend fun revokeOAuthToken(
|
||||
@Field("client_id") clientId: String,
|
||||
@Field("client_secret") clientSecret: String,
|
||||
@Field("token") token: String
|
||||
): NetworkResult<Unit>
|
||||
|
||||
@GET("/api/v1/lists")
|
||||
suspend fun getLists(): NetworkResult<List<MastoList>>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/lists")
|
||||
fun createList(
|
||||
suspend fun createList(
|
||||
@Field("title") title: String
|
||||
): Single<MastoList>
|
||||
): NetworkResult<MastoList>
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT("api/v1/lists/{listId}")
|
||||
fun updateList(
|
||||
suspend fun updateList(
|
||||
@Path("listId") listId: String,
|
||||
@Field("title") title: String
|
||||
): Single<MastoList>
|
||||
): NetworkResult<MastoList>
|
||||
|
||||
@DELETE("api/v1/lists/{listId}")
|
||||
fun deleteList(
|
||||
suspend fun deleteList(
|
||||
@Path("listId") listId: String
|
||||
): Completable
|
||||
): NetworkResult<Unit>
|
||||
|
||||
@GET("api/v1/lists/{listId}/accounts")
|
||||
fun getAccountsInList(
|
||||
suspend fun getAccountsInList(
|
||||
@Path("listId") listId: String,
|
||||
@Query("limit") limit: Int
|
||||
): Single<List<TimelineAccount>>
|
||||
): NetworkResult<List<TimelineAccount>>
|
||||
|
||||
@FormUrlEncoded
|
||||
// @DELETE doesn't support fields
|
||||
@HTTP(method = "DELETE", path = "api/v1/lists/{listId}/accounts", hasBody = true)
|
||||
fun deleteAccountFromList(
|
||||
suspend fun deleteAccountFromList(
|
||||
@Path("listId") listId: String,
|
||||
@Field("account_ids[]") accountIds: List<String>
|
||||
): Completable
|
||||
): NetworkResult<Unit>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/lists/{listId}/accounts")
|
||||
fun addCountToList(
|
||||
suspend fun addAccountToList(
|
||||
@Path("listId") listId: String,
|
||||
@Field("account_ids[]") accountIds: List<String>
|
||||
): Completable
|
||||
): NetworkResult<Unit>
|
||||
|
||||
@GET("/api/v1/conversations")
|
||||
suspend fun getConversations(
|
||||
@Query("max_id") maxId: String? = null,
|
||||
@Query("limit") limit: Int
|
||||
): List<Conversation>
|
||||
@Query("limit") limit: Int? = null
|
||||
): Response<List<Conversation>>
|
||||
|
||||
@DELETE("/api/v1/conversations/{id}")
|
||||
suspend fun deleteConversation(
|
||||
|
@ -538,24 +559,24 @@ interface MastodonApi {
|
|||
@GET("api/v1/announcements")
|
||||
suspend fun listAnnouncements(
|
||||
@Query("with_dismissed") withDismissed: Boolean = true
|
||||
): Result<List<Announcement>>
|
||||
): NetworkResult<List<Announcement>>
|
||||
|
||||
@POST("api/v1/announcements/{id}/dismiss")
|
||||
suspend fun dismissAnnouncement(
|
||||
@Path("id") announcementId: String
|
||||
): Result<ResponseBody>
|
||||
): NetworkResult<ResponseBody>
|
||||
|
||||
@PUT("api/v1/announcements/{id}/reactions/{name}")
|
||||
suspend fun addAnnouncementReaction(
|
||||
@Path("id") announcementId: String,
|
||||
@Path("name") name: String
|
||||
): Result<ResponseBody>
|
||||
): NetworkResult<ResponseBody>
|
||||
|
||||
@DELETE("api/v1/announcements/{id}/reactions/{name}")
|
||||
suspend fun removeAnnouncementReaction(
|
||||
@Path("id") announcementId: String,
|
||||
@Path("name") name: String
|
||||
): Result<ResponseBody>
|
||||
): NetworkResult<ResponseBody>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/reports")
|
||||
|
@ -591,10 +612,48 @@ interface MastodonApi {
|
|||
@Query("following") following: Boolean? = null
|
||||
): Single<SearchResult>
|
||||
|
||||
@GET("api/v2/search")
|
||||
fun searchSync(
|
||||
@Query("q") query: String?,
|
||||
@Query("type") type: String? = null,
|
||||
@Query("resolve") resolve: Boolean? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("offset") offset: Int? = null,
|
||||
@Query("following") following: Boolean? = null
|
||||
): NetworkResult<SearchResult>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/accounts/{id}/note")
|
||||
fun updateAccountNote(
|
||||
@Path("id") accountId: String,
|
||||
@Field("comment") note: String
|
||||
): Single<Relationship>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/push/subscription")
|
||||
suspend fun subscribePushNotifications(
|
||||
@Header("Authorization") auth: String,
|
||||
@Header(DOMAIN_HEADER) domain: String,
|
||||
@Field("subscription[endpoint]") endPoint: String,
|
||||
@Field("subscription[keys][p256dh]") keysP256DH: String,
|
||||
@Field("subscription[keys][auth]") keysAuth: String,
|
||||
// The "data[alerts][]" fields to enable / disable notifications
|
||||
// Should be generated dynamically from all the available notification
|
||||
// types defined in [com.keylesspalace.tusky.entities.Notification.Types]
|
||||
@FieldMap data: Map<String, Boolean>
|
||||
): NetworkResult<NotificationSubscribeResult>
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT("api/v1/push/subscription")
|
||||
suspend fun updatePushNotificationSubscription(
|
||||
@Header("Authorization") auth: String,
|
||||
@Header(DOMAIN_HEADER) domain: String,
|
||||
@FieldMap data: Map<String, Boolean>
|
||||
): NetworkResult<NotificationSubscribeResult>
|
||||
|
||||
@DELETE("api/v1/push/subscription")
|
||||
suspend fun unsubscribePushNotifications(
|
||||
@Header("Authorization") auth: String,
|
||||
@Header(DOMAIN_HEADER) domain: String,
|
||||
): NetworkResult<ResponseBody>
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.keylesspalace.tusky.network
|
||||
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import com.keylesspalace.tusky.entity.MediaUploadResult
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.http.Multipart
|
||||
|
@ -15,5 +16,5 @@ interface MediaUploadApi {
|
|||
suspend fun uploadMedia(
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part description: MultipartBody.Part? = null
|
||||
): Result<MediaUploadResult>
|
||||
): NetworkResult<MediaUploadResult>
|
||||
}
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
/* Copyright 2022 Tusky contributors
|
||||
*
|
||||
* 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.receiver
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import com.keylesspalace.tusky.components.notifications.canEnablePushNotifications
|
||||
import com.keylesspalace.tusky.components.notifications.isUnifiedPushNotificationEnabledForAccount
|
||||
import com.keylesspalace.tusky.components.notifications.updateUnifiedPushSubscription
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import dagger.android.AndroidInjection
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@DelicateCoroutinesApi
|
||||
class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() {
|
||||
@Inject
|
||||
lateinit var mastodonApi: MastodonApi
|
||||
|
||||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
AndroidInjection.inject(this, context)
|
||||
if (Build.VERSION.SDK_INT < 28) return
|
||||
if (!canEnablePushNotifications(context, accountManager)) return
|
||||
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
val gid = when (intent.action) {
|
||||
NotificationManager.ACTION_NOTIFICATION_CHANNEL_BLOCK_STATE_CHANGED -> {
|
||||
val channelId = intent.getStringExtra(NotificationManager.EXTRA_NOTIFICATION_CHANNEL_ID)
|
||||
nm.getNotificationChannel(channelId).group
|
||||
}
|
||||
NotificationManager.ACTION_NOTIFICATION_CHANNEL_GROUP_BLOCK_STATE_CHANGED -> {
|
||||
intent.getStringExtra(NotificationManager.EXTRA_NOTIFICATION_CHANNEL_GROUP_ID)
|
||||
}
|
||||
else -> null
|
||||
} ?: return
|
||||
|
||||
accountManager.getAccountByIdentifier(gid)?.let { account ->
|
||||
if (isUnifiedPushNotificationEnabledForAccount(account)) {
|
||||
// Update UnifiedPush notification subscription
|
||||
GlobalScope.launch { updateUnifiedPushSubscription(context, mastodonApi, accountManager, account) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
/* Copyright 2022 Tusky contributors
|
||||
*
|
||||
* 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.receiver
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationWorker
|
||||
import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint
|
||||
import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import dagger.android.AndroidInjection
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.unifiedpush.android.connector.MessagingReceiver
|
||||
import javax.inject.Inject
|
||||
|
||||
@DelicateCoroutinesApi
|
||||
class UnifiedPushBroadcastReceiver : MessagingReceiver() {
|
||||
companion object {
|
||||
const val TAG = "UnifiedPush"
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
@Inject
|
||||
lateinit var mastodonApi: MastodonApi
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
super.onReceive(context, intent)
|
||||
AndroidInjection.inject(this, context)
|
||||
}
|
||||
|
||||
override fun onMessage(context: Context, message: ByteArray, instance: String) {
|
||||
AndroidInjection.inject(this, context)
|
||||
Log.d(TAG, "New message received for account $instance")
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val request = OneTimeWorkRequest.from(NotificationWorker::class.java)
|
||||
workManager.enqueue(request)
|
||||
}
|
||||
|
||||
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
|
||||
AndroidInjection.inject(this, context)
|
||||
Log.d(TAG, "Endpoint available for account $instance: $endpoint")
|
||||
accountManager.getAccountById(instance.toLong())?.let {
|
||||
// Launch the coroutine in global scope -- it is short and we don't want to lose the registration event
|
||||
// and there is no saner way to use structured concurrency in a receiver
|
||||
GlobalScope.launch { registerUnifiedPushEndpoint(context, mastodonApi, accountManager, it, endpoint) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(context: Context, instance: String) = Unit
|
||||
|
||||
override fun onUnregistered(context: Context, instance: String) {
|
||||
AndroidInjection.inject(this, context)
|
||||
Log.d(TAG, "Endpoint unregistered for account $instance")
|
||||
accountManager.getAccountById(instance.toLong())?.let {
|
||||
// It's fine if the account does not exist anymore -- that means it has been logged out
|
||||
GlobalScope.launch { unregisterUnifiedPushEndpoint(mastodonApi, accountManager, it) }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -15,6 +15,7 @@ import android.util.Log
|
|||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent
|
||||
|
|
|
@ -62,6 +62,6 @@ object PrefKeys {
|
|||
const val NOTIFICATION_FILTER_SIGN_UPS = "notificationFilterSignUps"
|
||||
const val NOTIFICATION_FILTER_UPDATES = "notificationFilterUpdates"
|
||||
|
||||
const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies"
|
||||
const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies_v2" // This was changed once to reset an unintentionally set default.
|
||||
const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
package com.keylesspalace.tusky.usecase
|
||||
|
||||
import android.content.Context
|
||||
import com.keylesspalace.tusky.components.drafts.DraftHelper
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.components.notifications.disableUnifiedPushNotificationsForAccount
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.removeShortcut
|
||||
import javax.inject.Inject
|
||||
|
||||
class LogoutUsecase @Inject constructor(
|
||||
private val context: Context,
|
||||
private val api: MastodonApi,
|
||||
private val db: AppDatabase,
|
||||
private val accountManager: AccountManager,
|
||||
private val draftHelper: DraftHelper
|
||||
) {
|
||||
|
||||
/**
|
||||
* Logs the current account out and clears all caches associated with it
|
||||
* @return true if the user is logged in with other accounts, false if it was the only one
|
||||
*/
|
||||
suspend fun logout(): Boolean {
|
||||
accountManager.activeAccount?.let { activeAccount ->
|
||||
|
||||
// invalidate the oauth token, if we have the client id & secret
|
||||
// (could be missing if user logged in with a previous version of Tusky)
|
||||
val clientId = activeAccount.clientId
|
||||
val clientSecret = activeAccount.clientSecret
|
||||
if (clientId != null && clientSecret != null) {
|
||||
api.revokeOAuthToken(
|
||||
clientId = clientId,
|
||||
clientSecret = clientSecret,
|
||||
token = activeAccount.accessToken
|
||||
)
|
||||
}
|
||||
|
||||
// disable push notifications
|
||||
disableUnifiedPushNotificationsForAccount(context, activeAccount)
|
||||
|
||||
// disable pull notifications
|
||||
if (!NotificationHelper.areNotificationsEnabled(context, accountManager)) {
|
||||
NotificationHelper.disablePullNotifications(context)
|
||||
}
|
||||
|
||||
// clear notification channels
|
||||
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, context)
|
||||
|
||||
// remove account from local AccountManager
|
||||
val otherAccountAvailable = accountManager.logActiveAccountOut() != null
|
||||
|
||||
// clear the database - this could trigger network calls so do it last when all tokens are gone
|
||||
db.timelineDao().removeAll(activeAccount.id)
|
||||
db.conversationDao().deleteForAccount(activeAccount.id)
|
||||
draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id)
|
||||
|
||||
// remove shortcut associated with the account
|
||||
removeShortcut(context, activeAccount)
|
||||
|
||||
return otherAccountAvailable
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@
|
|||
* 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.network
|
||||
package com.keylesspalace.tusky.usecase
|
||||
|
||||
import android.util.Log
|
||||
import com.keylesspalace.tusky.appstore.BlockEvent
|
||||
|
@ -29,6 +29,7 @@ import com.keylesspalace.tusky.appstore.StatusDeletedEvent
|
|||
import com.keylesspalace.tusky.entity.DeletedStatus
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.addTo
|
60
app/src/main/java/com/keylesspalace/tusky/util/CryptoUtil.kt
Normal file
60
app/src/main/java/com/keylesspalace/tusky/util/CryptoUtil.kt
Normal file
|
@ -0,0 +1,60 @@
|
|||
/* Copyright 2022 Tusky contributors
|
||||
*
|
||||
* 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 android.util.Base64
|
||||
import org.bouncycastle.jce.ECNamedCurveTable
|
||||
import org.bouncycastle.jce.interfaces.ECPrivateKey
|
||||
import org.bouncycastle.jce.interfaces.ECPublicKey
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.SecureRandom
|
||||
import java.security.Security
|
||||
|
||||
object CryptoUtil {
|
||||
const val CURVE_PRIME256_V1 = "prime256v1"
|
||||
|
||||
private const val BASE64_FLAGS = Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
|
||||
|
||||
init {
|
||||
Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME)
|
||||
Security.addProvider(BouncyCastleProvider())
|
||||
}
|
||||
|
||||
private fun secureRandomBytes(len: Int): ByteArray {
|
||||
val ret = ByteArray(len)
|
||||
SecureRandom.getInstance("SHA1PRNG").nextBytes(ret)
|
||||
return ret
|
||||
}
|
||||
|
||||
fun secureRandomBytesEncoded(len: Int): String {
|
||||
return Base64.encodeToString(secureRandomBytes(len), BASE64_FLAGS)
|
||||
}
|
||||
|
||||
data class EncodedKeyPair(val pubkey: String, val privKey: String)
|
||||
|
||||
fun generateECKeyPair(curve: String): EncodedKeyPair {
|
||||
val spec = ECNamedCurveTable.getParameterSpec(curve)
|
||||
val gen = KeyPairGenerator.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME)
|
||||
gen.initialize(spec)
|
||||
val keyPair = gen.genKeyPair()
|
||||
val pubKey = keyPair.public as ECPublicKey
|
||||
val privKey = keyPair.private as ECPrivateKey
|
||||
val encodedPubKey = Base64.encodeToString(pubKey.q.getEncoded(false), BASE64_FLAGS)
|
||||
val encodedPrivKey = Base64.encodeToString(privKey.d.toByteArray(), BASE64_FLAGS)
|
||||
return EncodedKeyPair(encodedPubKey, encodedPrivKey)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
package com.keylesspalace.tusky.util
|
||||
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import retrofit2.HttpException
|
||||
|
||||
/**
|
||||
* checks if this throwable indicates an error causes by a 4xx/5xx server response and
|
||||
* tries to retrieve the error message the server sent
|
||||
* @return the error message, or null if this is no server error or it had no error message
|
||||
*/
|
||||
fun Throwable.getServerErrorMessage(): String? {
|
||||
if (this is HttpException) {
|
||||
val errorResponse = response()?.errorBody()?.string()
|
||||
return if (!errorResponse.isNullOrBlank()) {
|
||||
try {
|
||||
JSONObject(errorResponse).getString("error")
|
||||
} catch (e: JSONException) {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
|
@ -47,8 +47,8 @@ sealed class StatusViewData {
|
|||
get() = status.id
|
||||
|
||||
/**
|
||||
* Specifies whether the content of this post is allowed to be collapsed or if it should show
|
||||
* all content regardless.
|
||||
* Specifies whether the content of this post is long enough to be automatically
|
||||
* collapsed or if it should show all content regardless.
|
||||
*
|
||||
* @return Whether the post is collapsible or never collapsed.
|
||||
*/
|
||||
|
@ -106,7 +106,7 @@ sealed class StatusViewData {
|
|||
}
|
||||
|
||||
/** Helper for Java */
|
||||
fun copyWIthCollapsed(isCollapsed: Boolean): Concrete {
|
||||
fun copyWithCollapsed(isCollapsed: Boolean): Concrete {
|
||||
return copy(isCollapsed = isCollapsed)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,92 +17,104 @@
|
|||
package com.keylesspalace.tusky.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.Either
|
||||
import com.keylesspalace.tusky.util.Either.Left
|
||||
import com.keylesspalace.tusky.util.Either.Right
|
||||
import com.keylesspalace.tusky.util.RxAwareViewModel
|
||||
import com.keylesspalace.tusky.util.withoutFirstWhich
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class State(val accounts: Either<Throwable, List<TimelineAccount>>, val searchResult: List<TimelineAccount>?)
|
||||
|
||||
class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) : RxAwareViewModel() {
|
||||
class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() {
|
||||
|
||||
val state: Observable<State> get() = _state
|
||||
private val _state = BehaviorSubject.createDefault(State(Right(listOf()), null))
|
||||
val state: Flow<State> get() = _state
|
||||
private val _state = MutableStateFlow(State(Right(listOf()), null))
|
||||
|
||||
fun load(listId: String) {
|
||||
val state = _state.value!!
|
||||
val state = _state.value
|
||||
if (state.accounts.isLeft() || state.accounts.asRight().isEmpty()) {
|
||||
api.getAccountsInList(listId, 0).subscribe(
|
||||
{ accounts ->
|
||||
updateState { copy(accounts = Right(accounts)) }
|
||||
},
|
||||
{ e ->
|
||||
updateState { copy(accounts = Left(e)) }
|
||||
}
|
||||
).autoDispose()
|
||||
viewModelScope.launch {
|
||||
api.getAccountsInList(listId, 0).fold(
|
||||
{ accounts ->
|
||||
updateState { copy(accounts = Right(accounts)) }
|
||||
},
|
||||
{ e ->
|
||||
updateState { copy(accounts = Left(e)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addAccountToList(listId: String, account: TimelineAccount) {
|
||||
api.addCountToList(listId, listOf(account.id))
|
||||
.subscribe(
|
||||
{
|
||||
updateState {
|
||||
copy(accounts = accounts.map { it + account })
|
||||
viewModelScope.launch {
|
||||
api.addAccountToList(listId, listOf(account.id))
|
||||
.fold(
|
||||
{
|
||||
updateState {
|
||||
copy(accounts = accounts.map { it + account })
|
||||
}
|
||||
},
|
||||
{
|
||||
Log.i(
|
||||
javaClass.simpleName,
|
||||
"Failed to add account to list: ${account.username}"
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
Log.i(
|
||||
javaClass.simpleName,
|
||||
"Failed to add account to the list: ${account.username}"
|
||||
)
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAccountFromList(listId: String, accountId: String) {
|
||||
api.deleteAccountFromList(listId, listOf(accountId))
|
||||
.subscribe(
|
||||
{
|
||||
updateState {
|
||||
copy(
|
||||
accounts = accounts.map { accounts ->
|
||||
accounts.withoutFirstWhich { it.id == accountId }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
api.deleteAccountFromList(listId, listOf(accountId))
|
||||
.fold(
|
||||
{
|
||||
updateState {
|
||||
copy(
|
||||
accounts = accounts.map { accounts ->
|
||||
accounts.withoutFirstWhich { it.id == accountId }
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
Log.i(
|
||||
javaClass.simpleName,
|
||||
"Failed to remove account from list: $accountId"
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
Log.i(javaClass.simpleName, "Failed to remove account from thelist: $accountId")
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
when {
|
||||
query.isEmpty() -> updateState { copy(searchResult = null) }
|
||||
query.isBlank() -> updateState { copy(searchResult = listOf()) }
|
||||
else -> api.searchAccounts(query, null, 10, true)
|
||||
.subscribe(
|
||||
{ result ->
|
||||
updateState { copy(searchResult = result) }
|
||||
},
|
||||
{
|
||||
updateState { copy(searchResult = listOf()) }
|
||||
}
|
||||
).autoDispose()
|
||||
else -> viewModelScope.launch {
|
||||
api.searchAccounts(query, null, 10, true)
|
||||
.fold(
|
||||
{ result ->
|
||||
updateState { copy(searchResult = result) }
|
||||
},
|
||||
{
|
||||
updateState { copy(searchResult = listOf()) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun updateState(crossinline fn: State.() -> State) {
|
||||
_state.onNext(fn(_state.value!!))
|
||||
_state.value = fn(_state.value)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ import androidx.core.net.toUri
|
|||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.ProfileEditedEvent
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
|
@ -31,6 +32,7 @@ import com.keylesspalace.tusky.util.Error
|
|||
import com.keylesspalace.tusky.util.Loading
|
||||
import com.keylesspalace.tusky.util.Resource
|
||||
import com.keylesspalace.tusky.util.Success
|
||||
import com.keylesspalace.tusky.util.getServerErrorMessage
|
||||
import com.keylesspalace.tusky.util.randomAlphanumericString
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
|
@ -38,9 +40,6 @@ import okhttp3.MultipartBody
|
|||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import retrofit2.HttpException
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -155,21 +154,7 @@ class EditProfileViewModel @Inject constructor(
|
|||
eventHub.dispatch(ProfileEditedEvent(newProfileData))
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException) {
|
||||
val errorResponse = throwable.response()?.errorBody()?.string()
|
||||
val errorMsg = if (!errorResponse.isNullOrBlank()) {
|
||||
try {
|
||||
JSONObject(errorResponse).optString("error", "")
|
||||
} catch (e: JSONException) {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
saveData.postValue(Error(errorMessage = errorMsg))
|
||||
} else {
|
||||
saveData.postValue(Error())
|
||||
}
|
||||
saveData.postValue(Error(errorMessage = throwable.getServerErrorMessage()))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -16,19 +16,23 @@
|
|||
|
||||
package com.keylesspalace.tusky.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.entity.MastoList
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.RxAwareViewModel
|
||||
import com.keylesspalace.tusky.util.replacedFirstWhich
|
||||
import com.keylesspalace.tusky.util.withoutFirstWhich
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class ListsViewModel @Inject constructor(private val api: MastodonApi) : RxAwareViewModel() {
|
||||
internal class ListsViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() {
|
||||
enum class LoadingState {
|
||||
INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER
|
||||
}
|
||||
|
@ -39,86 +43,94 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi)
|
|||
|
||||
data class State(val lists: List<MastoList>, val loadingState: LoadingState)
|
||||
|
||||
val state: Observable<State> get() = _state
|
||||
val events: Observable<Event> get() = _events
|
||||
private val _state = BehaviorSubject.createDefault(State(listOf(), LoadingState.INITIAL))
|
||||
private val _events = PublishSubject.create<Event>()
|
||||
val state: Flow<State> get() = _state
|
||||
val events: Flow<Event> get() = _events
|
||||
private val _state = MutableStateFlow(State(listOf(), LoadingState.INITIAL))
|
||||
private val _events = MutableSharedFlow<Event>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
fun retryLoading() {
|
||||
loadIfNeeded()
|
||||
}
|
||||
|
||||
private fun loadIfNeeded() {
|
||||
val state = _state.value!!
|
||||
val state = _state.value
|
||||
if (state.loadingState == LoadingState.LOADING || state.lists.isNotEmpty()) return
|
||||
updateState {
|
||||
copy(loadingState = LoadingState.LOADING)
|
||||
}
|
||||
|
||||
api.getLists().subscribe(
|
||||
{ lists ->
|
||||
updateState {
|
||||
copy(
|
||||
lists = lists,
|
||||
loadingState = LoadingState.LOADED
|
||||
)
|
||||
viewModelScope.launch {
|
||||
api.getLists().fold(
|
||||
{ lists ->
|
||||
updateState {
|
||||
copy(
|
||||
lists = lists,
|
||||
loadingState = LoadingState.LOADED
|
||||
)
|
||||
}
|
||||
},
|
||||
{ err ->
|
||||
updateState {
|
||||
copy(
|
||||
loadingState = if (err is IOException || err is ConnectException)
|
||||
LoadingState.ERROR_NETWORK else LoadingState.ERROR_OTHER
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ err ->
|
||||
updateState {
|
||||
copy(
|
||||
loadingState = if (err is IOException || err is ConnectException)
|
||||
LoadingState.ERROR_NETWORK else LoadingState.ERROR_OTHER
|
||||
)
|
||||
}
|
||||
}
|
||||
).autoDispose()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createNewList(listName: String) {
|
||||
api.createList(listName).subscribe(
|
||||
{ list ->
|
||||
updateState {
|
||||
copy(lists = lists + list)
|
||||
viewModelScope.launch {
|
||||
api.createList(listName).fold(
|
||||
{ list ->
|
||||
updateState {
|
||||
copy(lists = lists + list)
|
||||
}
|
||||
},
|
||||
{
|
||||
sendEvent(Event.CREATE_ERROR)
|
||||
}
|
||||
},
|
||||
{
|
||||
sendEvent(Event.CREATE_ERROR)
|
||||
}
|
||||
).autoDispose()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun renameList(listId: String, listName: String) {
|
||||
api.updateList(listId, listName).subscribe(
|
||||
{ list ->
|
||||
updateState {
|
||||
copy(lists = lists.replacedFirstWhich(list) { it.id == listId })
|
||||
viewModelScope.launch {
|
||||
api.updateList(listId, listName).fold(
|
||||
{ list ->
|
||||
updateState {
|
||||
copy(lists = lists.replacedFirstWhich(list) { it.id == listId })
|
||||
}
|
||||
},
|
||||
{
|
||||
sendEvent(Event.RENAME_ERROR)
|
||||
}
|
||||
},
|
||||
{
|
||||
sendEvent(Event.RENAME_ERROR)
|
||||
}
|
||||
).autoDispose()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteList(listId: String) {
|
||||
api.deleteList(listId).subscribe(
|
||||
{
|
||||
updateState {
|
||||
copy(lists = lists.withoutFirstWhich { it.id == listId })
|
||||
viewModelScope.launch {
|
||||
api.deleteList(listId).fold(
|
||||
{
|
||||
updateState {
|
||||
copy(lists = lists.withoutFirstWhich { it.id == listId })
|
||||
}
|
||||
},
|
||||
{
|
||||
sendEvent(Event.DELETE_ERROR)
|
||||
}
|
||||
},
|
||||
{
|
||||
sendEvent(Event.DELETE_ERROR)
|
||||
}
|
||||
).autoDispose()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun updateState(crossinline fn: State.() -> State) {
|
||||
_state.onNext(fn(_state.value!!))
|
||||
_state.value = fn(_state.value)
|
||||
}
|
||||
|
||||
private fun sendEvent(event: Event) {
|
||||
_events.onNext(event)
|
||||
private suspend fun sendEvent(event: Event) {
|
||||
_events.emit(event)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -235,6 +235,19 @@
|
|||
tools:itemCount="2"
|
||||
tools:listitem="@layout/item_account_field" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/accountDateJoined"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
tools:text="April, 1971"
|
||||
android:textColor="@color/textColorSecondary"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/accountFieldList"
|
||||
app:layout_constraintBottom_toTopOf="@id/accountRemoveView"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/accountRemoveView"
|
||||
android:layout_width="match_parent"
|
||||
|
@ -245,7 +258,7 @@
|
|||
android:lineSpacingMultiplier="1.1"
|
||||
android:text="@string/label_remote_account"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintTop_toBottomOf="@id/accountFieldList"
|
||||
app:layout_constraintTop_toBottomOf="@id/accountDateJoined"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
|
|
|
@ -239,6 +239,7 @@
|
|||
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/composeBottomBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
|
|
|
@ -8,11 +8,13 @@
|
|||
android:fitsSystemWindows="true">
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
android:id="@+id/mainCoordinatorLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context="com.keylesspalace.tusky.MainActivity">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/appBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:elevation="@dimen/actionbar_elevation"
|
||||
|
@ -75,6 +77,13 @@
|
|||
|
||||
<include layout="@layout/item_status_bottom_sheet" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
android:layout_gravity="center" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
||||
<com.mikepenz.materialdrawer.widget.MaterialDrawerSliderView
|
||||
|
|
|
@ -16,8 +16,8 @@
|
|||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:contentInsetStartWithNavigation="0dp"
|
||||
android:elevation="@dimen/actionbar_elevation"
|
||||
app:contentInsetStartWithNavigation="0dp"
|
||||
app:layout_scrollFlags="scroll|snap|enterAlways"
|
||||
app:navigationIcon="?attr/homeAsUpIndicator" />
|
||||
|
||||
|
@ -27,8 +27,9 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:tabGravity="fill"
|
||||
app:tabMaxWidth="0dp"
|
||||
app:tabMode="fixed"
|
||||
app:tabTextAppearance="@style/TuskyTabAppearance"/>
|
||||
app:tabTextAppearance="@style/TuskyTabAppearance" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
|
@ -38,6 +39,6 @@
|
|||
android:layout_height="match_parent"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||
|
||||
<include layout="@layout/item_status_bottom_sheet"/>
|
||||
<include layout="@layout/item_status_bottom_sheet" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
@ -1,48 +1,65 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:padding="8dp">
|
||||
android:paddingStart="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/avatar"
|
||||
android:layout_width="42dp"
|
||||
android:layout_height="42dp"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:contentDescription="@null"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginEnd="24dp"
|
||||
android:foregroundGravity="center_vertical"
|
||||
android:importantForAccessibility="no"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:src="@drawable/avatar_default" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_toEndOf="@id/avatar"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical">
|
||||
<ImageView
|
||||
android:id="@+id/avatarBadge"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@string/profile_badge_bot_text"
|
||||
android:src="@drawable/bot_badge"
|
||||
app:layout_constraintBottom_toBottomOf="@id/avatar"
|
||||
app:layout_constraintEnd_toEndOf="@id/avatar" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/display_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:textSize="?attr/status_text_large"
|
||||
android:textStyle="normal|bold"
|
||||
tools:text="Conny Duck" />
|
||||
<TextView
|
||||
android:id="@+id/displayName"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="14dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:textSize="?attr/status_text_large"
|
||||
android:textStyle="normal|bold"
|
||||
app:layout_constraintBottom_toTopOf="@id/username"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/avatar"
|
||||
app:layout_constraintTop_toTopOf="@id/avatar"
|
||||
app:layout_constraintVertical_chainStyle="packed"
|
||||
tools:text="Display name" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/username"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
tools:text="\@ConnyDuck" />
|
||||
<TextView
|
||||
android:id="@+id/username"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="14dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
app:layout_constraintBottom_toBottomOf="@id/avatar"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/avatar"
|
||||
app:layout_constraintTop_toBottomOf="@id/displayName"
|
||||
tools:text="\@username" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</RelativeLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<View xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/status_divider" />
|
|
@ -5,24 +5,24 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp">
|
||||
tools:ignore="UseCompoundDrawables">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/preview"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:contentDescription="@null"
|
||||
android:padding="4dp" />
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:importantForAccessibility="no" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/shortcode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/hashtag"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
android:textStyle="normal|bold"
|
||||
app:drawableStartCompat="@drawable/ic_list"
|
||||
app:drawableTint="?attr/iconColor" />
|
||||
tools:text="#Tusky" />
|
||||
|
|
|
@ -319,6 +319,16 @@
|
|||
app:layout_constraintTop_toBottomOf="@id/status_poll_description"
|
||||
app:srcCompat="@drawable/ic_reply_24dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status_replies"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toBottomOf="@id/status_reply"
|
||||
app:layout_constraintStart_toEndOf="@id/status_reply"
|
||||
app:layout_constraintTop_toTopOf="@id/status_reply"
|
||||
android:textAlignment="viewEnd"
|
||||
android:textSize="?attr/status_text_medium" />
|
||||
|
||||
<at.connyduck.sparkbutton.SparkButton
|
||||
android:id="@+id/status_inset"
|
||||
android:layout_width="30dp"
|
||||
|
|
|
@ -269,7 +269,6 @@
|
|||
<string name="add_account_description">إضافة حساب ماستدون جديد</string>
|
||||
<string name="action_lists">القوائم</string>
|
||||
<string name="title_lists">القوائم</string>
|
||||
<string name="title_list_timeline">الخط الزمني للقائمة</string>
|
||||
<string name="error_create_list">لا يمكن إنشاء قائمة</string>
|
||||
<string name="error_rename_list">لا يمكن إعادة تسمية القائمة</string>
|
||||
<string name="error_delete_list">لا يمكن حذف القائمة</string>
|
||||
|
|
|
@ -151,7 +151,6 @@
|
|||
<string name="error_delete_list">Списъкът не можа да се изтрие</string>
|
||||
<string name="error_create_list">Списъкът не можа да се създаде</string>
|
||||
<string name="error_rename_list">Списъкът не можа да се преименува</string>
|
||||
<string name="title_list_timeline">Списъчна емисия</string>
|
||||
<string name="title_lists">Списъци</string>
|
||||
<string name="action_lists">Списъци</string>
|
||||
<string name="add_account_description">Добавяне на нов Mastodon акаунт</string>
|
||||
|
|
|
@ -75,7 +75,6 @@
|
|||
<string name="error_delete_list">তালিকা মুছে ফেলা যায়নি</string>
|
||||
<string name="error_rename_list">তালিকা নামকরণ করা যায়নি</string>
|
||||
<string name="error_create_list">তালিকা তৈরি করা যায়নি</string>
|
||||
<string name="title_list_timeline">তালিকা টাইমলাইনে রাখুন</string>
|
||||
<string name="title_lists">তালিকাসমূহ</string>
|
||||
<string name="action_lists">তালিকাসমূহ</string>
|
||||
<string name="add_account_description">নতুন ম্যাস্টোডোন অ্যাকাউন্ট যোগ করুন</string>
|
||||
|
|
|
@ -275,7 +275,6 @@
|
|||
<string name="add_account_description">নতুন ম্যাস্টোডোন অ্যাকাউন্ট যোগ করুন</string>
|
||||
<string name="action_lists">তালিকাসমূহ</string>
|
||||
<string name="title_lists">তালিকাসমূহ</string>
|
||||
<string name="title_list_timeline">তালিকা টাইমলাইনে রাখুন</string>
|
||||
<string name="error_create_list">তালিকা তৈরি করা যায়নি</string>
|
||||
<string name="error_rename_list">তালিকা নামকরণ করা যায়নি</string>
|
||||
<string name="error_delete_list">তালিকা মুছে ফেলা যায়নি</string>
|
||||
|
|
|
@ -275,7 +275,6 @@
|
|||
<string name="add_account_description">Afegir un compte de Mastodont</string>
|
||||
<string name="action_lists">Llistes</string>
|
||||
<string name="title_lists">Llistes</string>
|
||||
<string name="title_list_timeline">Cronologia de la llista</string>
|
||||
<string name="error_create_list">És impossible crear la llista</string>
|
||||
<string name="error_rename_list">Impossible reanomenar la llista</string>
|
||||
<string name="error_delete_list">És impossible suprimir la llista</string>
|
||||
|
|
|
@ -403,7 +403,6 @@
|
|||
<string name="error_delete_list">نەیتوانی لیستەکە بسڕێتەوە</string>
|
||||
<string name="error_rename_list">نەیتوانی ناوی لیست بنووسرێ</string>
|
||||
<string name="error_create_list">نەیتوانی لیست دروست بکات</string>
|
||||
<string name="title_list_timeline">لیستی تایم لاین</string>
|
||||
<string name="title_lists">لیستەکان</string>
|
||||
<string name="action_lists">لیستەکان</string>
|
||||
<string name="add_account_description">زیادکردنی ئەژمێری ماتۆدۆنی نوێ</string>
|
||||
|
|
|
@ -198,7 +198,7 @@
|
|||
<string name="pref_default_post_privacy">Výchozí soukromí příspěvků</string>
|
||||
<string name="pref_default_media_sensitivity">Vždy označovat média jako citlivá</string>
|
||||
<string name="pref_publishing">Publikování (synchronizováno se serverem)</string>
|
||||
<string name="pref_failed_to_sync">Nepodařilo se synchronizovsat nastavení</string>
|
||||
<string name="pref_failed_to_sync">Nepodařilo se synchronizovat nastavení</string>
|
||||
<string name="post_privacy_public">Veřejné</string>
|
||||
<string name="post_privacy_unlisted">Neuvedené</string>
|
||||
<string name="post_privacy_followers_only">Pouze pro sledující</string>
|
||||
|
@ -274,7 +274,6 @@
|
|||
<string name="add_account_description">Přidat nový účet Mastodon</string>
|
||||
<string name="action_lists">Seznamy</string>
|
||||
<string name="title_lists">Seznamy</string>
|
||||
<string name="title_list_timeline">Časová osa seznamu</string>
|
||||
<string name="error_create_list">Nelze vytvořit seznam</string>
|
||||
<string name="error_rename_list">Nelze přejmenovat seznam</string>
|
||||
<string name="error_delete_list">Nelze smazat seznam</string>
|
||||
|
@ -484,4 +483,12 @@
|
|||
<string name="pref_title_confirm_reblogs">Zobrazit dialogové okno s potvrzením při boostování</string>
|
||||
<string name="notification_subscription_format">%s právě vydal</string>
|
||||
<string name="title_announcements">Oznámení</string>
|
||||
<string name="title_login">Přihlášení</string>
|
||||
<string name="notification_sign_up_format">%s se zaregistroval</string>
|
||||
<string name="title_migration_relogin">Přihlaste se znovu pro oznámení</string>
|
||||
<string name="error_could_not_load_login_page">Nepodařilo se načíst stránku přihlášení.</string>
|
||||
<string name="drafts_post_failed_to_send">Tento příspěvek se nepodařilo poslat!</string>
|
||||
<string name="error_loading_account_details">Nepodařilo se načíst detaily účtu</string>
|
||||
<string name="drafts_failed_loading_reply">Nepodařilo se načíst informace o odpovědi</string>
|
||||
<string name="error_image_edit_failed">Obrázek se nepodařilo upravit.</string>
|
||||
</resources>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue