Merge remote-tracking branch 'tuskyapp/develop'

# Conflicts:
#	app/build.gradle
#	app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java
#	app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt
#	app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt
#	app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java
#	app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt
#	app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt
#	app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt
#	app/src/main/res/layout/activity_main.xml
#	app/src/main/res/values-cs/strings.xml
#	app/src/main/res/values-de/strings.xml
#	app/src/main/res/values-fa/strings.xml
#	app/src/main/res/values-fr/strings.xml
#	app/src/main/res/values-hu/strings.xml
#	app/src/main/res/values-it/strings.xml
#	app/src/main/res/values-no-rNB/strings.xml
#	app/src/main/res/values-zh-rCN/strings.xml
This commit is contained in:
kyori19 2022-07-16 02:10:58 +09:00
commit 57aab71b0e
No known key found for this signature in database
GPG Key ID: CB37D0651E7F52AA
113 changed files with 4584 additions and 840 deletions

View File

@ -108,7 +108,7 @@ ext.glideVersion = '4.13.1'
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'
repositories {
maven {
@ -154,7 +154,7 @@ dependencies {
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"
@ -165,7 +165,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"
@ -188,7 +188,7 @@ 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"
@ -202,6 +202,8 @@ dependencies {
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"

View 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')"
]
}
}

View 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')"
]
}
}

View File

@ -0,0 +1,893 @@
{
"formatVersion": 1,
"database": {
"version": 39,
"identityHash": "bf4c9d8417b71e549170a568522d513d",
"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, `quote` 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
},
{
"fieldPath": "quote",
"columnName": "quote",
"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, 'bf4c9d8417b71e549170a568522d513d')"
]
}
}

View File

@ -49,6 +49,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
@ -70,13 +71,10 @@ 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.disableUnifiedPushNotificationsForAccount
import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback
import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary
import com.keylesspalace.tusky.components.preference.PreferencesActivity
@ -94,11 +92,12 @@ import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.interfaces.ResettableFragment
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
@ -153,10 +152,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
lateinit var cacheUpdater: CacheUpdater
@Inject
lateinit var conversationRepository: ConversationsRepository
@Inject
lateinit var draftHelper: DraftHelper
lateinit var logoutUsecase: LogoutUsecase
@Inject
lateinit var viewModelFactory: ViewModelFactory
@ -834,28 +830,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 {
// Only disable UnifiedPush for this account -- do not call disableNotifications(),
// which unnecessarily disables it for all accounts and then re-enables it again at
// the next launch
disableUnifiedPushNotificationsForAccount(this@MainActivity, activeAccount)
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, LoginActivity.MODE_DEFAULT)
} 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()
@ -888,7 +874,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this)
// Setup push notifications
showMigrationNoticeIfNecessary(this, binding.root, accountManager)
showMigrationNoticeIfNecessary(this, binding.mainCoordinatorLayout, binding.composeButton, accountManager)
if (NotificationHelper.areNotificationsEnabled(this, accountManager)) {
lifecycleScope.launch {
enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager)

View File

@ -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 net.accelf.yuito.CustomUncaughtExceptionHandler
@ -37,12 +36,8 @@ class SplashActivity : AppCompatActivity(), Injectable {
Thread.setDefaultUncaughtExceptionHandler(CustomUncaughtExceptionHandler(applicationContext))
/** 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 {

View File

@ -31,6 +31,7 @@ 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

View File

@ -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)

View File

@ -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()
}
}
}

View File

@ -74,10 +74,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 ImageButton quoteButton;
@ -129,6 +129,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);
quoteButton = itemView.findViewById(R.id.status_quote);
@ -418,6 +419,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);
}
@ -841,6 +849,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setCreatedAt(actionable.getCreatedAt(), statusDisplayOptions);
setStatusVisibility(actionable.getVisibility());
setIsReply(actionable.getInReplyToId() != null);
setReplyCount(actionable.getRepliesCount());
setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
actionable.getAccount().getBot(), statusDisplayOptions);
setReblogged(actionable.getReblogged());
@ -1147,6 +1156,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());

View File

@ -107,18 +107,24 @@ 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());
}
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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.ConnectivityManager
@ -58,6 +59,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
@ -87,6 +91,7 @@ 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
@ -157,6 +162,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)
@ -191,6 +222,7 @@ class ComposeActivity :
viewModel.updateDescription(item.localId, newDescription)
}
},
onEditImage = this::editImageInQueue,
onRemove = this::removeMediaFromQueue
)
binding.composeMediaPreviewBar.layoutManager =
@ -429,9 +461,14 @@ class ComposeActivity :
enableButton(binding.composeAddMediaButton, active, active)
enablePollButton(media.isNullOrEmpty())
}.subscribe()
viewModel.uploadError.observe {
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
binding.composeEditField.requestFocus()
@ -594,19 +631,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()
@ -948,6 +989,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)
}

View File

@ -23,6 +23,7 @@ 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
@ -39,7 +40,6 @@ import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.combineLiveData
import com.keylesspalace.tusky.util.randomAlphanumericString
import com.keylesspalace.tusky.util.result
import com.keylesspalace.tusky.util.toLiveData
import io.reactivex.rxjava3.core.Observable
import kotlinx.coroutines.Dispatchers
@ -100,6 +100,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
fun loadInstanceDataFromNetwork(loadActually: Boolean) {
viewModelScope.launch {
emoji.postValue(when (loadActually) {
@ -133,13 +136,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,
@ -147,8 +153,19 @@ class ComposeViewModel @Inject constructor(
mediaSize = mediaSize,
description = description
)
stashMediaItem = mediaItem
if (replaceItem != null) {
mediaToJob[replaceItem.localId]?.cancel()
mediaValue.map {
if (it.localId == replaceItem.localId) mediaItem else it
}
} else { // Append
mediaValue + mediaItem
}.last()
}
}
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)
@ -213,7 +230,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
@ -346,8 +363,7 @@ class ComposeViewModel @Inject constructor(
fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
when (token[0]) {
'@' -> {
return api.searchAccountsCall(query = token.substring(1), limit = 10)
.result()
return api.searchAccountsSync(query = token.substring(1), limit = 10)
.fold({ accounts ->
accounts.map { AutocompleteResult.AccountResult(it) }
}, { e ->
@ -356,8 +372,7 @@ class ComposeViewModel @Inject constructor(
})
}
'#' -> {
return api.searchCall(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
.result()
return api.searchSync(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
.fold({ searchResult ->
searchResult.hashtags.map { AutocompleteResult.HashtagResult(it.name) }
}, { e ->

View File

@ -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

View File

@ -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,13 +225,21 @@ class MediaUploader @Inject constructor(
null
}
val result = mediaUploadApi.uploadMedia(body, description).getOrThrow()
mediaUploadApi.uploadMedia(body, description).fold({ result ->
if (media.uri.scheme == "file") {
media.uri.path?.let {
File(it).delete()
}
}
send(UploadEvent.FinishedEvent(result.id))
}, { throwable ->
val errorMessage = throwable.getServerErrorMessage()
if (errorMessage == null) {
throw throwable
} else {
throw UploadServerError(errorMessage)
}
})
awaitClose()
}
}
@ -245,7 +256,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

View File

@ -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
}
}
}
}

View File

@ -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,
@ -133,6 +138,7 @@ data class ConversationStatusEntity(
fun TimelineAccount.toEntity() =
ConversationAccountEntity(
id = id,
localUsername = localUsername,
username = username,
displayName = name,
avatar = avatar,
@ -150,6 +156,7 @@ fun Status.toEntity() =
createdAt = createdAt,
emojis = emojis,
favouritesCount = favouritesCount,
repliesCount = repliesCount,
favourited = favourited,
bookmarked = bookmarked,
sensitive = sensitive,
@ -164,10 +171,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()

View File

@ -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)
}
}

View File

@ -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,

View File

@ -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,9 +73,15 @@ 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();
if (payloads == null) {
TimelineAccount account = status.getAccount();
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener);
@ -118,6 +125,15 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
setConversationName(conversation.getAccounts());
setAvatars(conversation.getAccounts());
} else {
if (payloads instanceof List) {
for (Object item : (List<?>) payloads) {
if (Key.KEY_CREATED.equals(item)) {
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
}
}
}
}
}
private void setConversationName(List<ConversationAccountEntity> accounts) {

View File

@ -22,21 +22,28 @@ 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.components.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID
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
@ -45,29 +52,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)
@ -91,56 +100,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()
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))
}
} 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
eventHub.events
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { event ->
if (event is PreferenceChangedEvent) {
onPreferenceChanged(event.preferenceKey)
}
}
}
if (refreshState != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false
}
}
}
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() {
@ -207,7 +266,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) {
@ -252,6 +311,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)
@ -262,20 +334,20 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
.show()
}
private fun jumpToTop() {
if (isAdded) {
layoutManager?.scrollToPosition(0)
binding.recyclerView.stopScroll()
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)
}
}
override fun onReselect() {
jumpToTop()
}
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
adapter.peek(position)?.let { conversation ->
viewModel.voteInPoll(choices, conversation)
}
}

View File

@ -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 {
try {
val conversationsResult = when (loadType) {
LoadType.REFRESH -> {
api.getConversations(limit = state.config.initialLoadSize)
}
LoadType.PREPEND -> {
if (loadType == 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)
if (loadType == LoadType.REFRESH) {
nextKey = null
order = 0
}
try {
val conversationsResponse = api.getConversations(maxId = nextKey, limit = state.config.pageSize)
val conversations = conversationsResponse.body()
if (!conversationsResponse.isSuccessful || conversations == null) {
return MediatorResult.Error(HttpException(conversationsResponse))
}
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(
conversationsResult
conversations
.filterNot { it.lastStatus == null }
.map { it.toEntity(accountId) }
.map {
it.toEntity(accountId, order++)
}
)
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
}

View File

@ -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()
}
}

View File

@ -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) }
)

View File

@ -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

View File

@ -26,6 +26,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
@ -33,6 +34,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.viewBinding
@ -228,24 +230,48 @@ class LoginActivity : BaseActivity(), Injectable {
domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code"
).fold(
{ accessToken ->
accountManager.addAccount(accessToken.accessToken, domain, OAUTH_SCOPES)
fetchAccountDetails(accessToken, domain, clientId, clientSecret)
},
{ e ->
setLoading(false)
binding.domainTextInputLayout.error =
getString(R.string.error_retrieving_oauth_token)
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 ->
}, { 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),
)
}
)
getString(R.string.error_loading_account_details)
Log.e(TAG, getString(R.string.error_loading_account_details), e)
})
}
private fun setLoading(loadingState: Boolean) {

View File

@ -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)

View File

@ -458,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) {

View File

@ -23,6 +23,8 @@ 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
@ -49,7 +51,12 @@ private fun accountNeedsMigration(account: AccountEntity): Boolean =
fun currentAccountNeedsMigration(accountManager: AccountManager): Boolean =
accountManager.activeAccount?.let(::accountNeedsMigration) ?: false
fun showMigrationNoticeIfNecessary(context: Context, parent: View, accountManager: AccountManager) {
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
@ -57,10 +64,10 @@ fun showMigrationNoticeIfNecessary(context: Context, parent: View, accountManage
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).apply {
setAction(R.string.action_details) { showMigrationExplanationDialog(context, accountManager) }
show()
}
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) {
@ -87,7 +94,7 @@ private suspend fun enableUnifiedPushNotificationsForAccount(context: Context, a
// Already registered, update the subscription to match notification settings
updateUnifiedPushSubscription(context, api, accountManager, account)
} else {
UnifiedPush.registerAppWithDialog(context, account.id.toString())
UnifiedPush.registerAppWithDialog(context, account.id.toString(), features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE))
}
}
@ -150,7 +157,14 @@ private fun buildSubscriptionData(context: Context, account: AccountEntity): Map
}
// Called by UnifiedPush callback
suspend fun registerUnifiedPushEndpoint(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity, endpoint: String) {
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
@ -159,16 +173,12 @@ suspend fun registerUnifiedPushEndpoint(context: Context, api: MastodonApi, acco
val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1)
val auth = CryptoUtil.secureRandomBytesEncoded(16)
withContext(Dispatchers.IO) {
api.subscribePushNotifications(
"Bearer ${account.accessToken}", account.domain,
endpoint, keyPair.pubkey, auth,
buildSubscriptionData(context, account)
).onFailure {
Log.d(TAG, "Error setting push endpoint for account ${account.id}")
Log.d(TAG, Log.getStackTraceString(it))
Log.d(TAG, (it as HttpException).response().toString())
).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}")
@ -180,7 +190,6 @@ suspend fun registerUnifiedPushEndpoint(context: Context, api: MastodonApi, acco
account.unifiedPushUrl = endpoint
accountManager.saveAccount(account)
}
}
}
// Synchronize the enabled / disabled state of notifications with server-side subscription

View File

@ -39,7 +39,7 @@ class TabFilterPreferencesFragment : PreferenceFragmentCompat() {
checkBoxPreference {
setTitle(R.string.pref_title_show_replies)
key = PrefKeys.TAB_FILTER_HOME_REPLIES
setDefaultValue(false)
setDefaultValue(true)
isIconSpaceReserved = false
}
}

View File

@ -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

View File

@ -30,7 +30,7 @@ import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.NotestockApi
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

View File

@ -107,9 +107,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?) {
@ -244,7 +241,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) {
@ -259,9 +256,7 @@ class TimelineFragment :
}
}
}
}.also {
binding.recyclerView.addOnScrollListener(it)
}
})
}
eventHub.events
@ -297,8 +292,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)
@ -509,7 +503,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 {
@ -520,7 +514,7 @@ class TimelineFragment :
override fun onReselect() {
if (isAdded) {
layoutManager!!.scrollToPosition(0)
binding.recyclerView.layoutManager?.scrollToPosition(0)
binding.recyclerView.stopScroll()
}
}

View File

@ -99,6 +99,7 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
contentShowing = false,
pinned = false,
card = null,
repliesCount = 0,
quote = null,
)
}
@ -141,6 +142,7 @@ fun Status.toEntity(
contentCollapsed = contentCollapsed,
pinned = actionableStatus.pinned == true,
card = actionableStatus.card?.let(gson::toJson),
repliesCount = actionableStatus.repliesCount,
quote = actionableStatus.quote?.let(gson::toJson),
)
}
@ -186,6 +188,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
muted = status.muted,
poll = poll,
card = card,
repliesCount = status.repliesCount,
quote = quote,
)
}
@ -216,6 +219,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
muted = status.muted,
poll = null,
card = null,
repliesCount = status.repliesCount,
quote = null,
)
} else {
@ -245,6 +249,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
muted = status.muted,
poll = poll,
card = card,
repliesCount = status.repliesCount,
quote = quote,
)
}

View File

@ -51,6 +51,10 @@ class CachedTimelineRemoteMediator(
state: PagingState<Int, TimelineStatusWithAccount>
): MediatorResult {
if (!activeAccount.isLoggedIn()) {
return MediatorResult.Success(endOfPaginationReached = true)
}
try {
var dbEmpty = false

View File

@ -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,11 +38,12 @@ 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.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.viewdata.StatusViewData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
@ -69,7 +71,17 @@ class CachedTimelineViewModel @Inject constructor(
private val db: AppDatabase,
private val gson: Gson,
streamingManager: StreamingManager,
) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel, streamingManager) {
) : TimelineViewModel(
timelineCases,
api,
eventHub,
accountManager,
sharedPreferences,
filterModel,
streamingManager,
) {
private var currentPagingSource: PagingSource<Int, TimelineStatusWithAccount>? = null
@OptIn(ExperimentalPagingApi::class)
override val statuses = Pager(
@ -81,6 +93,8 @@ class CachedTimelineViewModel @Inject constructor(
EmptyTimelinePagingSource()
} else {
db.timelineDao().getStatuses(activeAccount.id)
}.also { newPagingSource ->
this.currentPagingSource = newPagingSource
}
}
).flow
@ -116,13 +130,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)
}
}
@ -149,12 +165,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()
@ -168,14 +193,19 @@ 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 ->
status.reblog?.account?.toEntity(activeAccount.id, gson)
?.let { rebloggedAccount ->
timelineDao.insertAccount(rebloggedAccount)
}
timelineDao.insertStatus(
@ -196,7 +226,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)
)
}
}
@ -211,7 +244,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) {
@ -266,6 +300,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
}

View File

@ -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
@ -270,6 +270,10 @@ class NetworkTimelineViewModel @Inject constructor(
currentSource?.invalidate()
}
override suspend fun invalidate() {
currentSource?.invalidate()
}
@Throws(IOException::class, HttpException::class)
suspend fun fetchStatusesForKind(
fromId: String?,

View File

@ -41,8 +41,8 @@ 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.settings.PrefKeys
import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
@ -140,6 +140,7 @@ abstract class TimelineViewModel(
this.isStreamingEnabled = isStreamingEnabled
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 =
@ -233,6 +234,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 ||
@ -353,6 +357,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()
}
}

View File

@ -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 = "",
@ -81,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

View File

@ -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, oauthScopes: String) {
fun addAccount(
accessToken: String,
domain: String,
clientId: String,
clientSecret: String,
oauthScopes: String,
newAccount: Account
) {
activeAccount?.let {
it.isActive = false
@ -62,13 +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
activeAccount = AccountEntity(
id = newAccountId, domain = domain.lowercase(Locale.ROOT),
accessToken = accessToken, oauthScopes = oauthScopes, isActive = true
)
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) }
}
activeAccount = newAccountEntity
updateActiveAccount(newAccount)
}
/**
@ -89,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
@ -103,7 +135,7 @@ class AccountManager @Inject constructor(db: AppDatabase) {
} else {
activeAccount = null
}
return activeAccount
activeAccount
}
}
@ -123,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)
}
}

View File

@ -31,7 +31,7 @@ import java.io.File;
*/
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class
}, version = 36)
}, version = 39)
public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao();
@ -554,4 +554,32 @@ public abstract class AppDatabase extends RoomDatabase {
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");
}
};
}

View File

@ -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)
}

View File

@ -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?
}

View File

@ -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,
s.quote,
@ -198,4 +198,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
}

View File

@ -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,

View File

@ -70,7 +70,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_35_36, AppDatabase.MIGRATION_36_37, AppDatabase.MIGRATION_37_38,
AppDatabase.MIGRATION_38_39
)
.build()
}

View File

@ -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
@ -40,6 +42,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
@ -52,7 +55,9 @@ class NetworkModule {
@Provides
@Singleton
fun providesGson() = Gson()
fun providesGson(): Gson = GsonBuilder()
.registerTypeAdapter(Date::class.java, Rfc3339DateJsonAdapter())
.create()
@Provides
@Singleton
@ -109,7 +114,7 @@ class NetworkModule {
.client(httpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
.addCallAdapterFactory(KotlinResultCallAdapterFactory.create())
.addCallAdapterFactory(NetworkResultCallAdapterFactory.create())
.build()
}

View File

@ -33,6 +33,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,

View File

@ -547,7 +547,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));
;
}
@ -972,10 +972,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);
}
@ -1240,7 +1240,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(

View File

@ -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;

View File

@ -397,7 +397,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
);
}

View 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
/*
* Jacksons 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
}

View File

@ -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()
}
}
}
}

View File

@ -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();
}
}

View File

@ -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()
}
}
}

View File

@ -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
@ -74,10 +75,10 @@ interface MastodonApi {
}
@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>>
@ -145,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(
@ -158,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(
@ -246,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")
@ -274,7 +278,7 @@ 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")
suspend fun searchAccounts(
@ -282,15 +286,15 @@ interface MastodonApi {
@Query("resolve") resolve: Boolean? = null,
@Query("limit") limit: Int? = null,
@Query("following") following: Boolean? = null
): Result<List<TimelineAccount>>
): NetworkResult<List<TimelineAccount>>
@GET("api/v1/accounts/search")
fun searchAccountsCall(
fun searchAccountsSync(
@Query("q") query: String,
@Query("resolve") resolve: Boolean? = null,
@Query("limit") limit: Int? = null,
@Query("following") following: Boolean? = null
): Call<List<TimelineAccount>>
): NetworkResult<List<TimelineAccount>>
@GET("api/v1/accounts/{id}")
fun account(
@ -445,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")
@ -456,34 +460,42 @@ 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(): Result<List<MastoList>>
suspend fun getLists(): NetworkResult<List<MastoList>>
@FormUrlEncoded
@POST("api/v1/lists")
suspend fun createList(
@Field("title") title: String
): Result<MastoList>
): NetworkResult<MastoList>
@FormUrlEncoded
@PUT("api/v1/lists/{listId}")
suspend fun updateList(
@Path("listId") listId: String,
@Field("title") title: String
): Result<MastoList>
): NetworkResult<MastoList>
@DELETE("api/v1/lists/{listId}")
suspend fun deleteList(
@Path("listId") listId: String
): Result<Unit>
): NetworkResult<Unit>
@GET("api/v1/lists/{listId}/accounts")
suspend fun getAccountsInList(
@Path("listId") listId: String,
@Query("limit") limit: Int
): Result<List<TimelineAccount>>
): NetworkResult<List<TimelineAccount>>
@FormUrlEncoded
// @DELETE doesn't support fields
@ -491,20 +503,20 @@ interface MastodonApi {
suspend fun deleteAccountFromList(
@Path("listId") listId: String,
@Field("account_ids[]") accountIds: List<String>
): Result<Unit>
): NetworkResult<Unit>
@FormUrlEncoded
@POST("api/v1/lists/{listId}/accounts")
suspend fun addAccountToList(
@Path("listId") listId: String,
@Field("account_ids[]") accountIds: List<String>
): Result<Unit>
): 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(
@ -547,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")
@ -601,14 +613,14 @@ interface MastodonApi {
): Single<SearchResult>
@GET("api/v2/search")
fun searchCall(
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
): Call<SearchResult>
): NetworkResult<SearchResult>
@FormUrlEncoded
@POST("api/v1/accounts/{id}/note")
@ -629,7 +641,7 @@ interface MastodonApi {
// Should be generated dynamically from all the available notification
// types defined in [com.keylesspalace.tusky.entities.Notification.Types]
@FieldMap data: Map<String, Boolean>
): Result<NotificationSubscribeResult>
): NetworkResult<NotificationSubscribeResult>
@FormUrlEncoded
@PUT("api/v1/push/subscription")
@ -637,11 +649,11 @@ interface MastodonApi {
@Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String,
@FieldMap data: Map<String, Boolean>
): Result<NotificationSubscribeResult>
): NetworkResult<NotificationSubscribeResult>
@DELETE("api/v1/push/subscription")
suspend fun unsubscribePushNotifications(
@Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String,
): Result<ResponseBody>
): NetworkResult<ResponseBody>
}

View File

@ -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>
}

View File

@ -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

View File

@ -72,6 +72,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"
}

View File

@ -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
}
}

View File

@ -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

View File

@ -1,23 +0,0 @@
package com.keylesspalace.tusky.util
import retrofit2.Call
import retrofit2.HttpException
/**
* Synchronously executes the call and returns the response encapsulated in a kotlin.Result.
* Since Result is an inline class it is not possible to do this with a Retrofit adapter unfortunately.
* More efficient then calling a suspending method with runBlocking
*/
fun <T> Call<T>.result(): Result<T> {
return try {
val response = execute()
val responseBody = response.body()
if (response.isSuccessful && responseBody != null) {
Result.success(responseBody)
} else {
Result.failure(HttpException(response))
}
} catch (e: Exception) {
Result.failure(e)
}
}

View File

@ -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
}

View File

@ -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.
*/
@ -109,7 +109,7 @@ sealed class StatusViewData {
}
/** Helper for Java */
fun copyWIthCollapsed(isCollapsed: Boolean): Concrete {
fun copyWithCollapsed(isCollapsed: Boolean): Concrete {
return copy(isCollapsed = isCollapsed)
}
}

View File

@ -19,6 +19,7 @@ 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

View File

@ -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()))
}
)
}

View File

@ -18,6 +18,7 @@ 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.replacedFirstWhich

View File

@ -1,110 +0,0 @@
package net.accelf.yuito;
import android.content.Intent;
import android.os.Bundle;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import com.google.android.material.textfield.TextInputEditText;
import com.keylesspalace.tusky.MainActivity;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable;
import java.io.IOException;
import javax.inject.Inject;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
public class AccessTokenLoginActivity extends AppCompatActivity implements Injectable {
@Inject
AccountManager accountManager;
TextInputEditText domainEditText;
TextInputEditText accessTokenEditText;
TextView logTextView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_access_token_login);
domainEditText = findViewById(R.id.domainEditText);
accessTokenEditText = findViewById(R.id.accessTokenEditText);
Button authorizeButton = findViewById(R.id.authorizeButton);
logTextView = findViewById(R.id.logTextView);
authorizeButton.setOnClickListener(v -> authorize());
log("Input domain and access token to login.");
}
private void log(String text) {
runOnUiThread(() -> logTextView.setText(String.format("%s\n%s", logTextView.getText().toString(), text)));
}
private void authorize() {
if (domainEditText.getText() != null) {
String domain = domainEditText.getText().toString();
String accessToken = accessTokenEditText.getText().toString();
HttpUrl url;
log("Starting login test. [domain: " + domain + ", accessToken: " + accessToken + "]");
try {
url = new HttpUrl.Builder().host(domain).scheme("https")
.addPathSegments("/api/v1/accounts/verify_credentials")
.addQueryParameter("access_token", accessToken)
.build();
} catch (IllegalArgumentException e) {
log("Wrong domain format. " + e.getMessage());
log("Aborting.");
return;
}
log("Access start -> " + url.toString());
OkHttpClient okHttpClient = new OkHttpClient();
Request request = new Request.Builder().url(url).get().build();
okHttpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(@NonNull Call call, @NonNull IOException e) {
log("Login failed. " + e.getMessage());
log("Aborting.");
}
@Override
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
if (response.body() != null) {
log(response.body().string());
}
if (response.code() != 200) {
throw new IOException("Invalid response code. Response code was " + response.code());
}
log("Login successful. Moving to account registration phase.");
authSucceeded(domain, accessToken);
}
});
}
}
private void authSucceeded(String domain, String accessToken) {
accountManager.addAccount(accessToken, domain, "");
log("Completed. Enjoy!");
Intent intent = new Intent(this, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
startActivity(intent);
finish();
overridePendingTransition(R.anim.explode, R.anim.explode);
}
}

View File

@ -0,0 +1,71 @@
package net.accelf.yuito
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import at.connyduck.calladapter.networkresult.onFailure
import at.connyduck.calladapter.networkresult.onSuccess
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityAccessTokenLoginBinding
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.viewBinding
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
class AccessTokenLoginActivity : AppCompatActivity(), Injectable {
@Inject
lateinit var accountManager: AccountManager
@Inject
lateinit var mastodonApi: MastodonApi
private val binding by viewBinding(ActivityAccessTokenLoginBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
binding.authorizeButton.setOnClickListener {
it.isEnabled = false
runBlocking { authorize() }
it.isEnabled = true
}
log("Input domain and access token to login.")
}
private fun log(text: String) {
runOnUiThread {
binding.logTextView.text = String.format("%s\n%s", binding.logTextView.text.toString(), text)
}
}
private suspend fun authorize() {
if (binding.domainEditText.text.isNullOrBlank()) {
return
}
val domain = binding.domainEditText.text.toString()
val accessToken = binding.accessTokenEditText.text.toString()
log("Starting login test. [domain: $domain, accessToken: $accessToken]")
mastodonApi.accountVerifyCredentials(domain, auth = "Bearer $accessToken")
.onSuccess { account ->
log("Login successful. Moving to account registration phase.")
accountManager.addAccount(accessToken, domain, "", "", "", account)
log("Completed. Enjoy!")
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)
}
.onFailure { e ->
log("Login failed. ${e.message}")
log("Aborting.")
}
}
}

View File

@ -4,6 +4,9 @@ import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import at.connyduck.calladapter.networkresult.NetworkResult
import at.connyduck.calladapter.networkresult.onFailure
import at.connyduck.calladapter.networkresult.onSuccess
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemDrawerFooterBinding
import com.keylesspalace.tusky.entity.Instance
@ -34,10 +37,10 @@ class FooterDrawerItem : AbstractDrawerItem<FooterDrawerItem, BindingHolder<Item
override fun getViewHolder(v: View): BindingHolder<ItemDrawerFooterBinding> = throw UnsupportedOperationException()
fun setInstance(instance: Result<Instance>) {
fun setInstance(instance: NetworkResult<Instance>) {
instance
.onSuccess {
binding.instanceData.text = String.format("%s\n%s\n%s", it.title, it.uri, it.version)
binding.instanceData.text = listOf(it.title, it.uri, it.version).joinToString("\n")
}
.onFailure {
binding.instanceData.text = binding.root.context.getString(R.string.instance_data_failed)

View File

@ -297,6 +297,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"

View File

@ -14,11 +14,13 @@
tools:context="com.keylesspalace.tusky.MainActivity">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/mainCoordinatorLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1">
<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"
@ -81,6 +83,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>
<net.accelf.yuito.QuickTootView

View File

@ -332,6 +332,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"

View File

@ -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>
@ -483,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>

View File

@ -536,4 +536,9 @@
<string name="title_login">Anmelden</string>
<string name="error_could_not_load_login_page">Die Anmeldeseite konnte nicht geladen werden.</string>
<string name="notification_update_name">Beitragsbearbeitungen</string>
<string name="title_migration_relogin">Neuanmeldung für Push-Benachrichtigungen</string>
<string name="action_dismiss">Ablehnen</string>
<string name="dialog_push_notification_migration_other_accounts">Du hast dich erneut in dein aktuelles Konto eingeloggt, um Tusky die Genehmigung für Push-Abonnements zu erteilen. Du hast jedoch noch andere Konten, die nicht auf diese Weise migriert wurden. Wechsel zu diesen Konten und melde dich nacheinander neu an, um die Unterstützung für UnifiedPush-Benachrichtigungen zu aktivieren.</string>
<string name="dialog_push_notification_migration">Um Push-Benachrichtigungen über UnifiedPush verwenden zu können, benötigt Tusky die Erlaubnis, Benachrichtigungen auf Ihrem Mastodon-Server zu abonnieren. Dies erfordert eine erneute Anmeldung, um die Tusky gewährten OAuth-Bereiche zu ändern. Wenn du die Option zum erneuten Einloggen hier oder in den Kontoeinstellungen verwendest, bleiben alle deine lokalen Entwürfe und der Cache erhalten.</string>
<string name="tips_push_notification_migration">Melde alle Konten neu an, um die Unterstützung für Push-Benachrichtigungen zu aktivieren.</string>
</resources>

View File

@ -8,22 +8,22 @@
<string name="error_authorization_unknown">خطای احراز هویت ناشناخته‌ای رخ داد.</string>
<string name="error_authorization_denied">احراز هویت رد شد.</string>
<string name="error_retrieving_oauth_token">دریافت ژتون ورود شکست خورد.</string>
<string name="error_compose_character_limit">وضعیت خیلی طولانی است!</string>
<string name="error_compose_character_limit">فرسته خیلی طولانی است!</string>
<string name="error_image_upload_size">پرونده باید کمتر از ۸ مگابایت باشد.</string>
<string name="error_video_upload_size">پرونده ویدئویی باید کمتر از ۴۰ مگابایت باشد.</string>
<string name="error_media_upload_type">این گونهٔ پرونده نمی‌تواند بارگذاری شود.</string>
<string name="error_media_upload_opening">این پرونده نتوانست گشوده شود.</string>
<string name="error_media_upload_permission">نیاز به اجازهٔ خواندن رسانه است.</string>
<string name="error_media_download_permission">نیاز به اجازهٔ ذخیرهٔ رسانه است.</string>
<string name="error_media_upload_image_or_video">تصاویر و فیلم‌ها هر دو نمی‌توانند به یک وضعیت ضمیمه شوند.</string>
<string name="error_media_upload_image_or_video">تصاویر و فیلم‌ها نمی‌توانند به یک فرسته پیوست شوند.</string>
<string name="error_media_upload_sending">بارگذاری شکست خورد.</string>
<string name="error_sender_account_gone">خطای فرستادن بوق.</string>
<string name="error_sender_account_gone">خطای فرستادن فرسته.</string>
<string name="title_home">خانه</string>
<string name="title_notifications">آگاهی‌ها</string>
<string name="title_public_local">محلّی</string>
<string name="title_public_federated">همگانی</string>
<string name="title_view_thread">بوق</string>
<string name="title_posts">فرسته</string>
<string name="title_view_thread">رشته</string>
<string name="title_posts">فرستهها</string>
<string name="title_posts_with_replies">با پاسخ‌</string>
<string name="title_follows">دنبال شونده</string>
<string name="title_followers">پی‌گیر</string>
@ -41,10 +41,10 @@
<string name="post_content_warning_show_more">نمایش بیش‌تر</string>
<string name="post_content_warning_show_less">نمایش کم‌تر</string>
<string name="post_content_show_more">گسترش</string>
<string name="post_content_show_less">بستن</string>
<string name="post_content_show_less">جمع کردن</string>
<string name="footer_empty">این‌جا هیچ‌چیز نیست. برای تازه‌سازی، به پایین بکشید!</string>
<string name="notification_reblog_format">%s بوقتان را تقویت کرد</string>
<string name="notification_favourite_format">%s بوقتان را برگزید</string>
<string name="notification_reblog_format">%s فرسته‌تان را تقویت کرد</string>
<string name="notification_favourite_format">%s فرسته‌تان را برگزید</string>
<string name="notification_follow_format">%s پی‌گیرتان شد</string>
<string name="report_username_format">گزارش @%s</string>
<string name="report_comment_hint">نظرهای اضافی؟</string>
@ -93,13 +93,13 @@
<string name="action_reject">رد</string>
<string name="action_search">جست‌وجو</string>
<string name="action_access_drafts">پیش‌نویس‌ها</string>
<string name="action_toggle_visibility">نمایانی بوق</string>
<string name="action_toggle_visibility">نمایانی فرسته</string>
<string name="action_content_warning">هشدار محتوا</string>
<string name="action_emoji_keyboard">صفحه‌کلید اموجی</string>
<string name="download_image">درحال بارگیری %1$s</string>
<string name="action_copy_link">رونوشت از پیوند</string>
<string name="send_post_link_to">هم‌رسانی نشانی بوق با…</string>
<string name="send_post_content_to">هم‌رسانی بوق با…</string>
<string name="send_post_link_to">هم‌رسانی نشانی فرسته با…</string>
<string name="send_post_content_to">هم‌رسانی فرسته با…</string>
<string name="send_media_to">هم‌رسانی رسانه با…</string>
<string name="confirmation_reported">فرستاده شد!</string>
<string name="confirmation_unblocked">کاربرنامسدود شد</string>
@ -130,7 +130,7 @@
<string name="dialog_download_image">بارگیری</string>
<string name="dialog_message_cancel_follow_request">درخواست دنبال کردن را لغو می‌کنید؟</string>
<string name="dialog_unfollow_warning">ناپیگیری این حساب؟</string>
<string name="dialog_delete_post_warning">حذف این بوق؟</string>
<string name="dialog_delete_post_warning">حذف این فرسته؟</string>
<string name="visibility_public">عمومی: فرستادن به خط زمانی‌های عمومی</string>
<string name="visibility_unlisted">فهرست‌نشده: نشان ندادن در خط زمانی‌های عمومی</string>
<string name="visibility_private">تنها دنبال‌کنندگان:پست فقط به دنبال‌کنندگان</string>
@ -156,7 +156,7 @@
<string name="pref_title_browser_settings">مرورگر</string>
<string name="pref_title_custom_tabs">استفاده از زبانه‌های سفارشی کروم</string>
<string name="pref_title_hide_follow_button">نهفتن دکمهٔ ایجاد، هنگام پیمایش</string>
<string name="pref_title_post_filter">فیلتر کردن خط زمانی</string>
<string name="pref_title_post_filter">پالایش خط زمانی</string>
<string name="pref_title_post_tabs">زبانه‌ها</string>
<string name="pref_title_show_boosts">نمایش تقویت‌ها</string>
<string name="pref_title_show_replies">نمایش پاسخ‌ها</string>
@ -173,10 +173,10 @@
<string name="post_privacy_public">عمومی</string>
<string name="post_privacy_unlisted">فهرست‌نشده</string>
<string name="post_privacy_followers_only">فقط پی‌گیران</string>
<string name="pref_post_text_size">اندازهٔ متن وضعیت</string>
<string name="pref_post_text_size">اندازهٔ متن فرسته</string>
<string name="post_text_size_smallest">کوچک‌ترین</string>
<string name="post_text_size_small">کوچک</string>
<string name="post_text_size_medium">متوسط</string>
<string name="post_text_size_medium">میانه</string>
<string name="post_text_size_large">بزرگ</string>
<string name="post_text_size_largest">بزرگ‌ترین</string>
<string name="notification_mention_name">اشاره‌های جدید</string>
@ -184,9 +184,9 @@
<string name="notification_follow_name">پی‌گیران جدید</string>
<string name="notification_follow_description">آگاهی‌ها دربارهٔ پی‌گیران جدید</string>
<string name="notification_boost_name">تقویت‌ها</string>
<string name="notification_boost_description">آگاهی‌ها هنگام تقویت شدن بوق‌هایتان</string>
<string name="notification_boost_description">آگاهی‌ها هنگام تقویت فرسته‌هایتان</string>
<string name="notification_favourite_name">برگزیدن‌ها</string>
<string name="notification_favourite_description">آگاهی‌ها هنگام برگزیده شدن بوق‌هایتان</string>
<string name="notification_favourite_description">آگاهی‌ها هنگام برگزیده شدن فرسته‌هایتان</string>
<string name="notification_mention_format">%s به شما اشاره کرد</string>
<string name="notification_summary_large">%1$s، %2$s، %3$s و %4$d دیگر</string>
<string name="notification_summary_medium">%1$s، %2$s و %3$s</string>
@ -209,8 +209,8 @@
<string name="about_bug_feature_request_site">گزارش مشکلات و درخواست ویژگی‌ها:
\n https://github.com/accelforce/Yuito/issues</string>
<string name="about_tusky_account">نمایهٔ تاسکی</string>
<string name="post_share_content">هم‌رسانی محتوای بوق</string>
<string name="post_share_link">هم‌رسانی پیوند بوق</string>
<string name="post_share_content">هم‌رسانی محتوای فرسته</string>
<string name="post_share_link">هم‌رسانی پیوند فرسته</string>
<string name="post_media_images">تصویرها</string>
<string name="post_media_video">ویدیو</string>
<string name="state_follow_requested">تقاضای پیگیری شد</string>
@ -240,19 +240,19 @@
<string name="lock_account_label">قفل حساب</string>
<string name="lock_account_label_description">لازم است پی‌گیران را دستی تأیید کنید</string>
<string name="compose_save_draft">ذخیرهٔ پیش‌نویس؟</string>
<string name="send_post_notification_title">در حال فرستادن بوق</string>
<string name="send_post_notification_error_title">خطای فرستادن بوق</string>
<string name="send_post_notification_channel_name">در حال فرستادن بوقها</string>
<string name="send_post_notification_title">فرستادن فرسته</string>
<string name="send_post_notification_error_title">خطا در فرستادن فرسته</string>
<string name="send_post_notification_channel_name">فرستادن فرستهها</string>
<string name="send_post_notification_cancel_title">فرستادن لغو شد</string>
<string name="send_post_notification_saved_content">رونوشتی از بوق در پیش‌نویس‌هایتان ذخیره شد</string>
<string name="send_post_notification_saved_content">رونوشتی از فرسته در پیش‌نویس‌هایتان ذخیره شد</string>
<string name="action_compose_shortcut">ایجاد</string>
<string name="error_no_custom_emojis">نمونه‌تان %s هیچ اموجی سفارشی‌ای ندارد</string>
<string name="emoji_style">سبک اموجی</string>
<string name="system_default">پیش‌گزیدهٔ سامانه</string>
<string name="download_fonts">نخست باید این مجموعه‌های اموجی را بارگیری کنید</string>
<string name="performing_lookup_title">در حال جست‌وجو…</string>
<string name="expand_collapse_all_posts">گسترده/جمع کردن تمام وضعیتها</string>
<string name="action_open_post">گشودن بوق</string>
<string name="expand_collapse_all_posts">گسترش/جمع کردن تمام فرستهها</string>
<string name="action_open_post">گشودن فرسته</string>
<string name="restart_required">نیاز به آغاز دوبارهٔ کاره</string>
<string name="restart_emoji">برای اعمال این تغییرات، نیاز به شروع دوبارهٔ تاسکی دارید</string>
<string name="later">بعداً</string>
@ -278,7 +278,7 @@
<string name="error_network">یک خطای شبکه رخ داد! لطفا اتصال خود را بررسی و دوباره تلاش کنید!</string>
<string name="title_direct_messages">پیام‌های مستقیم</string>
<string name="title_tab_preferences">زبانه‌ها</string>
<string name="title_posts_pinned">سنجاقشده</string>
<string name="title_posts_pinned">سنجاق شده</string>
<string name="title_domain_mutes">دامنه‌های نهفته</string>
<string name="post_username_format">\@%s</string>
<string name="message_empty">این‌جا هیچ‌چیزی نیست.</string>
@ -304,7 +304,7 @@
<string name="download_media">بارگیری رسانه</string>
<string name="downloading_media">در حال بارگیری رسانه</string>
<string name="confirmation_domain_unmuted">%s نانهفته</string>
<string name="dialog_redraft_post_warning">می‌خواهید این بوق را پاک و بازنویسی کنید؟</string>
<string name="dialog_redraft_post_warning">حذف و بازنویسی این فرسته؟</string>
<string name="mute_domain_warning_dialog_ok">نهفتن تمام دامنه</string>
<string name="pref_title_notification_filter_poll">پایان نظرسنجی‌ها</string>
<string name="pref_title_timeline_filters">پالایه‌ها</string>
@ -320,7 +320,7 @@
<string name="abbreviated_hours_ago">%d ساعت</string>
<string name="abbreviated_minutes_ago">%d دقیقه</string>
<string name="abbreviated_seconds_ago">%d ثانیه</string>
<string name="pref_title_alway_open_spoiler">گسترش همیشگی بوق‌های علامت‌خورده با هشدار محتوا</string>
<string name="pref_title_alway_open_spoiler">گسترش همیشگی فرسته‌های علامت‌خورده با هشدار محتوا</string>
<string name="pref_title_public_filter_keywords">خط زمانی‌های عمومی</string>
<string name="pref_title_thread_filter_keywords">گفت‌وگوها</string>
<string name="filter_addition_dialog_title">افزودن پالایه</string>
@ -360,8 +360,8 @@
</plurals>
<string name="description_post_media">رسانه: %s</string>
<string name="description_post_cw">هشدار محتوا: %s</string>
<string name="description_post_media_no_description_placeholder">بدون هیچ توضیحی</string>
<string name="description_post_reblogged">بازبوقیده</string>
<string name="description_post_media_no_description_placeholder">بدون شرح</string>
<string name="description_post_reblogged">تقویت شده</string>
<string name="description_post_favourited">برگزیده</string>
<string name="description_visiblity_public">عمومی</string>
<string name="description_visiblity_unlisted">فهرست‌نشده</string>
@ -373,7 +373,7 @@
<string name="notifications_clear">پاک‌سازی</string>
<string name="notifications_apply_filter">پالایش</string>
<string name="filter_apply">اعمال</string>
<string name="compose_shortcut_long_label">ایجاد بوق</string>
<string name="compose_shortcut_long_label">ایجاد فرسته</string>
<string name="compose_shortcut_short_label">ایجاد</string>
<string name="notification_clear_text">مطمئنید می‌خواهید تمام آگاهی‌هایتان را برای همیشه پاک کنید؟</string>
<string name="poll_info_time_absolute">پایان در %s</string>
@ -400,7 +400,7 @@
<string name="hint_additional_info">نظرهای اضافی</string>
<string name="report_remote_instance">هدایت به %s</string>
<string name="failed_report">شکست در گزارش</string>
<string name="failed_fetch_posts">شکست در واکشی وضعیتها</string>
<string name="failed_fetch_posts">شکست در واکشی فرستهها</string>
<string name="title_accounts">حساب‌ها</string>
<string name="failed_search">شکست در جست‌وجو</string>
<string name="pref_title_show_notifications_filter">نمایش پالایهٔ آگاهی‌ها</string>
@ -416,10 +416,10 @@
<string name="poll_allow_multiple_choices">گزینه‌های چندگانه</string>
<string name="poll_new_choice_hint">گزینهٔ %d</string>
<string name="edit_poll">ویرایش</string>
<string name="title_scheduled_posts">بوق‌های زمان‌بسته</string>
<string name="title_scheduled_posts">فرسته‌های زمان‌بسته</string>
<string name="action_edit">ویرایش</string>
<string name="action_access_scheduled_posts">بوق‌های زمان‌بسته</string>
<string name="action_schedule_post">بوق زمان‌بسته</string>
<string name="action_access_scheduled_posts">فرسته‌های زمان‌بسته</string>
<string name="action_schedule_post">فرستهٔ زمان‌بسته</string>
<string name="action_reset_schedule">بازنشانی</string>
<string name="mute_domain_warning">مطمئنید می‌خواهید تمام %s را مسدود کنید؟ محتوای آن دامنه را در هیچ‌یک از خط زمانی‌ها یا در آگاهی‌هایتان نخواهید دید. پی‌گیرانتان از آن دامنه، برداشته خواهند شد.</string>
<string name="filter_dialog_whole_word_description">هنگامی که کلیدواژه یا عبارت، فقط حروف‌عددی باشد، فقط اگر با تمام واژه مطابق باشد، اعمال خواهد شد</string>
@ -437,7 +437,7 @@
<string name="select_list_title">گزینش فهرست</string>
<string name="list">فهرست</string>
<string name="no_drafts">هیچ پیش‌نویسی ندارید.</string>
<string name="no_scheduled_posts">هیچ وضعیت زمان‌بسته‌ای ندارید.</string>
<string name="no_scheduled_posts">هیچ فرستهٔ زمان‌بسته‌ای ندارید.</string>
<string name="warning_scheduling_interval">ماستودون، بازهٔ زمان‌بندی‌ای با کمینهٔ ۵ دقیقه دارد.</string>
<string name="pref_title_confirm_reblogs">نمایش گفت‌وگوی تأیید، پیش از تقویت</string>
<string name="pref_title_show_cards_in_timelines">پیش‌نمایش پیوندها در خط‌زمانی‌ها</string>
@ -482,7 +482,7 @@
<string name="action_unsubscribe_account">عدم اشتراک</string>
<string name="action_subscribe_account">اشتراک</string>
<string name="draft_deleted">پیش‌نویس حذف شد</string>
<string name="drafts_post_failed_to_send">فرستادن این بوق شکست خورد!</string>
<string name="drafts_post_failed_to_send">فرستادن این فرسته شکست خورد!</string>
<string name="wellbeing_hide_stats_profile">نهفتن آمار کمی روی نمایه‌ها</string>
<string name="wellbeing_hide_stats_posts">نهفتن آمار کمی روی فرسته‌ها</string>
<string name="limit_notifications">محدود کردن آگاهی‌های خط‌زمانی</string>
@ -491,17 +491,17 @@
<string name="label_duration">طول</string>
<string name="post_media_attachments">پیوست‌ها</string>
<string name="post_media_audio">صدا</string>
<string name="notification_subscription_description">آگاهی‌ها هنگام انتشار بوقی جدید از کسی که مشترکش هستید</string>
<string name="notification_subscription_name">بوق‌های جدید</string>
<string name="notification_subscription_description">آگاهی‌ها هنگام انتشار فرسته‌ای جدید از کسی که پی‌می‌گیرید</string>
<string name="notification_subscription_name">فرسته‌های جدید</string>
<string name="pref_title_animate_custom_emojis">اموجی‌های شخصی متحرّک</string>
<string name="pref_title_notification_filter_subscriptions">کسی که مشترکش شده‌ام، بوقی جدید منتشر کرد</string>
<string name="pref_title_notification_filter_subscriptions">کسی که پی‌می‌گیرم، فرسته‌ای جدید منتشر کرد</string>
<string name="notification_subscription_format">%s چیزی فرستاد</string>
<string name="drafts_post_reply_removed">بوقی که پاسخی به آن را پیش‌نویس کردید، برداشته شده</string>
<string name="drafts_post_reply_removed">فرسته‌ای که پاسخی به آن را پیش‌نویس کردید، برداشته شده</string>
<string name="drafts_failed_loading_reply">شکست در بار کردن اطّلاعات پاسخ</string>
<string name="wellbeing_mode_notice">برخی اطّلاعات که ممکن است روی سلامتی ذهنیتان تأثیر بگذارد، پنهان خواهند شد. همچون:
\n
\n - آگاهی‌های برگزیدن، تقویت و پی‌گیری
\n - شمار برگزیدن و تقویت بوقها
\n - شمار برگزیدن و تقویت فرستهها
\n - آمار پی‌گیر و فرسته روی نمایه‌ها
\n
\n فرستادن آگاهی‌ها تأثیر نمی‌پذیرد، ولی می‌توانید ترجیحات آگاهیتان را به صورت دستی بازبینی کنید.</string>
@ -514,4 +514,34 @@
<string name="follow_requests_info">با این که حسابتان قفل نیست، کارکنان %1$s فکر کردند ممکن است بخواهید درخواست‌های پی‌گیری از این حساب‌ها را دستی بازبینی کنید.</string>
<string name="dialog_delete_conversation_warning">حذف این گفت‌وگو؟</string>
<string name="action_delete_conversation">حذف گفت‌وگو</string>
<string name="account_date_joined">در %1$s پیوست</string>
<string name="title_login">ورود</string>
<string name="notification_sign_up_format">%s ثبت‌نام کرد</string>
<string name="pref_title_confirm_favourites">نمایش گفت‌وگوی تأیید پیش از برگزیدن</string>
<string name="tusky_compose_post_quicksetting_label">ایجاد فرسته</string>
<string name="tips_push_notification_migration">ورود دوباره به تمامی حساب‌ها برای به کار انداختن پشتیبانی آگاهی‌های ارسالی.</string>
<string name="notification_update_description">آگاهی‌ها هنگام ویرایش فرسته‌هایی که با آن‌ها تعامل داشته‌اید</string>
<string name="action_unbookmark">برداشن نشانک</string>
<string name="dialog_push_notification_migration_other_accounts">برای اعطای اجازهٔ اشتراک آگاهی‌های ارسالی ، دوباره به حسابتان وارد شدید. با این حال هنوز حساب‌هایی دیگر دارید که این‌گونه مهاجرت داده نشده‌اند. به آن‌ها رفته و برای به کار انداختن پشتیبانی آگاهی‌های UnifiedPush یکی‌یکی دوباره وارد شوید.</string>
<string name="action_logout_confirm">مطمئنید که می‌خواهید از حساب %1$s خارج شوید؟</string>
<string name="duration_14_days">۱۴ روز</string>
<string name="duration_30_days">۳۰ روز</string>
<string name="duration_60_days">۶۰ روز</string>
<string name="duration_90_days">۹۰ روز</string>
<string name="duration_365_days">۳۶۵ روز</string>
<string name="duration_180_days">۱۸۰ روز</string>
<string name="status_count_one_plus">۱+</string>
<string name="dialog_push_notification_migration">تاسکی برای استفاده از آگاهی‌های ارسالی با UnifiedPush نیاز به اجازهٔ اشتراک آگاهی‌ها روی کارساز ماستودنتان دارد. این کار نیازمند ورود دوباره برای تغییر حوزه‌های OAuth اعطایی به تاسکی است. استفاده از گزینهٔ ورود دوباره در این‌جا یا در ترجیحات حساب، تمامی انباره‌ها و پیش‌نویس‌های محلیتان را نگه خواهد داشت.</string>
<string name="error_could_not_load_login_page">نتوانست صفحهٔ ورود را بار کند.</string>
<string name="pref_title_notification_filter_sign_ups">کسی ثبت‌نام کرد</string>
<string name="notification_update_name">ویرایش‌های فرسته</string>
<string name="action_edit_image">ویرایش تصویر</string>
<string name="notification_update_format">%s فرسته‌اش را ویراست</string>
<string name="pref_title_notification_filter_updates">فرسته‌ای که با آن تعامل داشته‌ام ویرایش شده</string>
<string name="notification_sign_up_name">ثبت‌نام‌ها</string>
<string name="notification_sign_up_description">آگاهی‌ها دربارهٔ کاربران جدید</string>
<string name="title_migration_relogin">ورود دوباره برای آگاهی‌های ارسالی</string>
<string name="action_dismiss">رد کردن</string>
<string name="action_details">جزییات</string>
<string name="saving_draft">ذخیرهٔ پیش‌نویس…</string>
</resources>

View File

@ -27,7 +27,7 @@
<string name="title_tab_preferences">Onglets</string>
<string name="title_view_thread">Fil</string>
<string name="title_posts">Messages</string>
<string name="title_posts_with_replies">Pouets &amp; réponses</string>
<string name="title_posts_with_replies">Avec réponses</string>
<string name="title_posts_pinned">Épinglés</string>
<string name="title_follows">Abonnements</string>
<string name="title_followers">Abonné·e·s</string>
@ -42,7 +42,7 @@
<string name="post_boosted_format">%s a partagé</string>
<string name="post_sensitive_media_title">Contenu sensible</string>
<string name="post_media_hidden_title">Média caché</string>
<string name="post_sensitive_media_directions">Cliquer pour voir</string>
<string name="post_sensitive_media_directions">Appuyer pour voir</string>
<string name="post_content_warning_show_more">Voir plus</string>
<string name="post_content_warning_show_less">Voir moins</string>
<string name="post_content_show_more">Déplier</string>
@ -156,7 +156,7 @@
<string name="dialog_download_image">Télécharger</string>
<string name="dialog_message_cancel_follow_request">Révoquer la demande dabonnement ?</string>
<string name="dialog_unfollow_warning">Ne plus suivre ce compte ?</string>
<string name="dialog_delete_post_warning">Supprimer ce pouet ?</string>
<string name="dialog_delete_post_warning">Supprimer ce message \?</string>
<string name="visibility_public">Public : afficher dans les fils publics</string>
<string name="visibility_unlisted">Non listé : ne pas afficher dans les fils publics</string>
<string name="visibility_private">Abonné·e·s uniquement : seul·e·s vos abonné·e·s verront vos statuts</string>
@ -202,7 +202,7 @@
<string name="post_privacy_public">Public</string>
<string name="post_privacy_unlisted">Non listé</string>
<string name="post_privacy_followers_only">Abonné·e·s uniquement</string>
<string name="pref_post_text_size">Taille du texte pour les statuts</string>
<string name="pref_post_text_size">Taille du texte des messages</string>
<string name="post_text_size_smallest">Plus petit</string>
<string name="post_text_size_small">Petit</string>
<string name="post_text_size_medium">Moyen</string>
@ -243,17 +243,17 @@
https://github.com/accelforce/Yuito/issues
</string>
<string name="about_tusky_account">Profil de Yuito</string>
<string name="post_share_content">Partager le contenu du pouet</string>
<string name="post_share_link">Partager le lien du pouet</string>
<string name="post_share_content">Partager le contenu du message</string>
<string name="post_share_link">Partager le lien du message</string>
<string name="post_media_images">Images</string>
<string name="post_media_video">Vidéo</string>
<string name="state_follow_requested">Demande dabonnement effectuée</string>
<!--These are for timestamps on statuses. For example: "16s" or "2d"-->
<string name="abbreviated_in_years">en %da</string>
<string name="abbreviated_in_days">en %dj</string>
<string name="abbreviated_in_hours">en %dh</string>
<string name="abbreviated_in_minutes">en %dm</string>
<string name="abbreviated_in_seconds">en %ds</string>
<string name="abbreviated_in_years">dans %da</string>
<string name="abbreviated_in_days">dans %dj</string>
<string name="abbreviated_in_hours">dans %dh</string>
<string name="abbreviated_in_minutes">dans %dm</string>
<string name="abbreviated_in_seconds">dans %ds</string>
<string name="abbreviated_years_ago">%da</string>
<string name="abbreviated_days_ago">%dj</string>
<string name="abbreviated_hours_ago">%dh</string>
@ -264,7 +264,7 @@
<string name="title_media">Média</string>
<string name="replying_to">Réponse à @%s</string>
<string name="load_more_placeholder_text">en charger plus</string>
<string name="pref_title_public_filter_keywords">Timelines publiques</string>
<string name="pref_title_public_filter_keywords">Fils publics</string>
<string name="pref_title_thread_filter_keywords">Conversations</string>
<string name="filter_addition_dialog_title">Ajouter un filtre</string>
<string name="filter_edit_dialog_title">Modifier un filtre</string>
@ -296,19 +296,19 @@
<string name="lock_account_label">Verrouiller le compte</string>
<string name="lock_account_label_description">Vous devez approuver manuellement les abonnements</string>
<string name="compose_save_draft">Enregistrer comme brouillon ?</string>
<string name="send_post_notification_title">Envoi du pouet</string>
<string name="send_post_notification_error_title">Erreur lors de lenvoi du pouet</string>
<string name="send_post_notification_channel_name">Envoi des pouets</string>
<string name="send_post_notification_title">Envoi du message</string>
<string name="send_post_notification_error_title">Erreur lors de lenvoi du message</string>
<string name="send_post_notification_channel_name">Envoi des messages</string>
<string name="send_post_notification_cancel_title">Envoi annulé</string>
<string name="send_post_notification_saved_content">Une copie du pouet a été sauvegardée dans vos brouillons</string>
<string name="send_post_notification_saved_content">Une copie du message a été sauvegardée dans vos brouillons</string>
<string name="action_compose_shortcut">Écrire</string>
<string name="error_no_custom_emojis">Votre instance %s na pas démojis personnalisés</string>
<string name="emoji_style">Style démojis</string>
<string name="system_default">Par défaut du système</string>
<string name="download_fonts">Vous devez commencer par télécharger ces jeux démojis</string>
<string name="performing_lookup_title">Recherche en cours…</string>
<string name="expand_collapse_all_posts">Déplier/replier tout les statuts</string>
<string name="action_open_post">Ouvrir le pouet</string>
<string name="expand_collapse_all_posts">Déplier/replier tout les messages</string>
<string name="action_open_post">Ouvrir le message</string>
<string name="restart_required">Un redémarrage de lapplication est nécessaire</string>
<string name="restart_emoji">Vous devrez redémarrer Yuito pour appliquer ces modifications</string>
<string name="later">Plus tard</string>
@ -350,16 +350,12 @@
<item quantity="one">maximum de %1$d onglet atteint</item>
<item quantity="other">maximum de %1$d onglets atteint</item>
</plurals>
<string name="description_post_media"> Média : %s
</string>
<string name="description_post_media">Média : %s</string>
<string name="description_post_cw"> Avertissement : %s
</string>
<string name="description_post_media_no_description_placeholder"> Pas de description
</string>
<string name="description_post_reblogged"> Reblogué
</string>
<string name="description_post_favourited"> Mis en favoris
</string>
<string name="description_post_media_no_description_placeholder">Aucune description</string>
<string name="description_post_reblogged">Partagé</string>
<string name="description_post_favourited">Mis en favoris</string>
<string name="description_visiblity_public"> Public
</string>
<string name="description_visiblity_unlisted">Non listé</string>
@ -377,7 +373,7 @@
<string name="pref_title_bot_overlay">Afficher l\'indicateur de robots</string>
<string name="notification_clear_text">Désirez-vous nettoyer toutes vos notifications de façon permanente \?</string>
<string name="action_delete_and_redraft">Effacer et ré-écrire</string>
<string name="dialog_redraft_post_warning">Effacer et ré-écrire ce pouet\?</string>
<string name="dialog_redraft_post_warning">Effacer et ré-écrire ce message\?</string>
<string name="poll_info_time_absolute">Termina à %s</string>
<string name="poll_info_closed">Terminé</string>
<string name="poll_vote">Voter</string>
@ -390,15 +386,15 @@
<item quantity="other">%d jours restants</item>
</plurals>
<plurals name="poll_timespan_hours">
<item quantity="one">%d heure restant</item>
<item quantity="one">%d heure restante</item>
<item quantity="other">%d heures restantes</item>
</plurals>
<plurals name="poll_timespan_minutes">
<item quantity="one">%d minute restant</item>
<item quantity="one">%d minute restante</item>
<item quantity="other">%d minutes restantes</item>
</plurals>
<plurals name="poll_timespan_seconds">
<item quantity="one">%d seconde restant</item>
<item quantity="one">%d seconde restante</item>
<item quantity="other">%d secondes restantes</item>
</plurals>
<string name="pref_title_animate_gif_avatars">Activer lanimation des avatars</string>
@ -418,7 +414,7 @@
<string name="hint_additional_info">Commentaires additionnels</string>
<string name="report_remote_instance">Transférer à %s</string>
<string name="failed_report">Échec du signalement</string>
<string name="failed_fetch_posts">Échec de récupération des statuts</string>
<string name="failed_fetch_posts">Échec de récupération des messages</string>
<string name="report_description_1">Le rapport sera envoyé aux modérateur·rice·s de votre instance. Vous pouvez expliquer pourquoi vous signalez le compte ci-dessous :</string>
<string name="mute_domain_warning">Êtes-vous sûr⋅e de vouloir bloquer %s en entier \? Vous ne verrez plus de contenu provenant de ce domaine, ni dans les fils publics, ni dans vos notifications. Vos abonné·e·s utilisant ce domaine seront retiré·e·s.</string>
<string name="button_done">Terminé</string>
@ -444,8 +440,8 @@
<string name="edit_poll">Éditer</string>
<string name="title_scheduled_posts">Pouets planifiés</string>
<string name="action_edit">Éditer</string>
<string name="action_access_scheduled_posts">Pouets programmés</string>
<string name="action_schedule_post">Planifier le pouet</string>
<string name="action_access_scheduled_posts">Messages programmés</string>
<string name="action_schedule_post">Planifier le message</string>
<string name="action_reset_schedule">Réinitialiser</string>
<string name="post_lookup_error_format">Erreur lors de la recherche du post %s</string>
<string name="about_powered_by_tusky">Propulsé par Tusky</string>
@ -457,7 +453,7 @@
<string name="list">Liste</string>
<string name="error_audio_upload_size">Les fichiers audio doivent avoir moins de 40 Mo.</string>
<string name="no_drafts">Vous navez aucun brouillon.</string>
<string name="no_scheduled_posts">Vous navez aucun pouet planifié.</string>
<string name="no_scheduled_posts">Vous navez aucun message planifié.</string>
<string name="warning_scheduling_interval">Lintervalle minimum de planification sur Mastodon est de 5 minutes.</string>
<string name="notification_follow_request_name">Demandes d\'abonnement</string>
<string name="dialog_block_warning">Bloquer @%s \?</string>
@ -528,7 +524,7 @@
<string name="post_media_audio">Audio</string>
<string name="pref_title_confirm_favourites">Demander confirmation avant de mettre en favoris</string>
<string name="drafts_post_reply_removed">Le message auquel répondait ce brouillon a été supprimé</string>
<string name="drafts_post_failed_to_send">Échec denvoi du pouet !</string>
<string name="drafts_post_failed_to_send">Échec denvoi du message !</string>
<string name="follow_requests_info">Bien que votre compte ne soit pas verrouillé, léquipe de %1$s a pensé que vous voudriez valider manuellement les demandes de dabonnement provenant de ces comptes.</string>
<string name="drafts_failed_loading_reply">Échec du chargement des informations de réponse</string>
<string name="duration_30_days">30 jours</string>
@ -540,11 +536,23 @@
<string name="tusky_compose_post_quicksetting_label">Rédiger un message</string>
<string name="notification_sign_up_format">%s a créé un compte</string>
<string name="notification_sign_up_name">Nouveaux comptes</string>
<string name="notification_sign_up_description">Notifications quand quelqu\'un crée un nouveau compte</string>
<string name="notification_sign_up_description">Notifications quand quelquun crée un nouveau compte</string>
<string name="pref_title_notification_filter_sign_ups">un nouveau compte a été créé</string>
<string name="notification_update_format">%s a modifié son message</string>
<string name="pref_title_notification_filter_updates">un message avec lequel j\'ai interagi est modifié</string>
<string name="pref_title_notification_filter_updates">un message avec lequel jai interagi est modifié</string>
<string name="notification_update_name">Messages modifiés</string>
<string name="notification_update_description">Notifications quand un post avec lequel vous avez interagi est modifié</string>
<string name="notification_update_description">Notifications quand un message avec lequel vous avez interagi est modifié</string>
<string name="title_login">Se connecter</string>
<string name="account_date_joined">Ici depuis %1$s</string>
<string name="action_details">Détails</string>
<string name="saving_draft">Sauvegarde du brouillon …</string>
<string name="status_count_one_plus">&gt;1</string>
<string name="dialog_push_notification_migration_other_accounts">Tusky peut maintenant recevoir les notifications instantanées de ce compte. Cependant, d\'autres de vos comptes n\'ont pas encore accès aux notifications instantanées. Basculez sur chacun de vos comptes et reconnectez les afin de recevoir les notifications avec UnifiedPush.</string>
<string name="error_could_not_load_login_page">La page de connexion ne peut être chargée.</string>
<string name="action_edit_image">Retoucher limage</string>
<string name="action_dismiss">Fermer</string>
<string name="title_migration_relogin">Se reconnecter pour recevoir les notifications instantanées</string>
<string name="dialog_push_notification_migration">Afin de recevoir les notifications via UnifiedPush, Tusky doit demander à votre serveur Mastodon la permission de sinscrire aux notifications. Ceci nécessite une reconnexion de vos comptes afin de changer les droits OAuth accordés a Tusky. En utilisant loption de reconnexion ici ou dans les préférences de compte, vos brouillons et le cache seront préservés.</string>
<string name="tips_push_notification_migration">Reconnectez tous vos comptes pour activer les notifications instantanées.</string>
<string name="error_image_edit_failed">L\'image na pas pu être retouchée.</string>
</resources>

View File

@ -556,4 +556,11 @@
<string name="title_login">Clàraich a-steach</string>
<string name="error_could_not_load_login_page">Cha b urrainn dhuinn duilleag a chlàraidh a-steach fhosgladh.</string>
<string name="saving_draft">A sàbhaladh na dreuchd…</string>
<string name="action_dismiss">Leig seachad</string>
<string name="action_details">Fiosrachadh</string>
<string name="account_date_joined">Air ballrachd fhaighinn %1$s</string>
<string name="tips_push_notification_migration">Clàraich a-steach às ùr leis a h-uile cunntas a chur na taice ri brathan putaidh an comas.</string>
<string name="title_migration_relogin">Clàraich a-steach às ùr airson brathan putaidh</string>
<string name="status_count_one_plus">1+</string>
<string name="action_edit_image">Deasaich an dealbh</string>
</resources>

View File

@ -52,8 +52,8 @@
<string name="action_block">Bloquear</string>
<string name="action_unfollow">Deixar de seguir</string>
<string name="action_follow">Seguir</string>
<string name="action_logout_confirm">Tes a certeza de que queres desconectar a conta %1$s\?</string>
<string name="action_logout">Desconectar</string>
<string name="action_logout_confirm">Tes a certeza de que queres pechar sesión da conta %1$s\?</string>
<string name="action_logout">Pechar sesión</string>
<string name="action_login">Accede con Mastodon</string>
<string name="action_compose">Redactar</string>
<string name="action_more">Máis</string>
@ -521,4 +521,20 @@
<string name="pref_title_notification_filter_sign_ups">hai unha nova usuaria</string>
<string name="notification_sign_up_name">Rexistros</string>
<string name="notification_sign_up_description">Notificacións sobre novas usuarias</string>
<string name="pref_title_notification_filter_updates">Foi editada unha publicación coa que interactuei</string>
<string name="notification_update_name">Edicións da publicación</string>
<string name="account_date_joined">Creada %1$s</string>
<string name="tips_push_notification_migration">Volve a acceder con tódalas contas para activar as notificacións push.</string>
<string name="title_login">Acceder</string>
<string name="notification_update_description">Notificacións cando son editadas publicacións coas que interactuaches</string>
<string name="dialog_push_notification_migration">Para poder usar as notificacións push vía UnifiedPush, Tusky require o permiso para subscribirse ás notificacións do teu servidor Mastodon. É necesario volver a acceder para cambiar os ámbitos OAuth concedidos a Tusky. Usando aquí ou nas Preferencias da Conta a opción de volver a acceder conservarás os borradores locais e caché.</string>
<string name="dialog_push_notification_migration_other_accounts">Volveches a acceder para obter as notificacións push en Tusky. Aínda así tes algunha outra conta que non foi migrada a este modo. Cambia a esas contas e volve a conectar unha a unha para activar o soporte para notificacións de UnifiedPush.</string>
<string name="title_migration_relogin">Volve a acceder para ter notificacións push</string>
<string name="notification_update_format">%s editou a publicación</string>
<string name="action_dismiss">Desbotar</string>
<string name="action_details">Detalles</string>
<string name="error_could_not_load_login_page">Non se puido cargar a páxina de inicio.</string>
<string name="saving_draft">Gardando borrador…</string>
<string name="status_count_one_plus">1+</string>
<string name="action_edit_image">Editar imaxe</string>
</resources>

View File

@ -533,4 +533,18 @@
<string name="notification_update_name">Bejegyzések szerkesztése</string>
<string name="notification_update_description">Értesítések olyan bejegyzések szerkesztéséről, melyekkel már dolgod volt</string>
<string name="tusky_compose_post_quicksetting_label">Bejegyzés Létrehozása</string>
<string name="title_migration_relogin">Bejelentkezés újra a leküldési értesítések érdekében</string>
<string name="action_dismiss">Elvetés</string>
<string name="action_details">Részletek</string>
<string name="account_date_joined">Csatlakozva %1$s</string>
<string name="tips_push_notification_migration">Bejelentkezés újra minden fiókkal a leküldéses értesítések engedélyezése érdekében.</string>
<string name="title_login">Bejelentkezés</string>
<string name="status_count_one_plus">1+</string>
<string name="error_could_not_load_login_page">Nem tudtuk betölteni a bejelentkező oldalt.</string>
<string name="saving_draft">Vázlat mentése…</string>
<string name="dialog_push_notification_migration">Ahhoz, hogy használhass leküldési értesítéseket a UnifiedPush szolgáltatással, a Tusky-nak fel kell iratkoznia az értesítésekre a Mastodon szervereden. Ehhez új bejelentkezésre van szükség, hogy a Tusky számára kiosztott OAuth jogosultságok megváltozzanak. Az újbóli bejelentkezés funkció használata itt vagy a Fiókbeállításoknál meg fogja őrizni a helyi piszkozataidat és a cache tartalmát.</string>
<string name="dialog_push_notification_migration_other_accounts">Újra bejelentkeztél a fiókodba, hogy feliratkoztasd a Tusky-t a leküldési értesítések használatára. Ugyanakkor vannak még fiókjaid, melyek még nem lettek így migrálva. Válts át rájuk és jelentkezz be újra mindegyikben, hogy ezekben is engedélyezd a UnifiedPush értesítések támogatását.</string>
<string name="action_edit_image">Kép szerkesztése</string>
<string name="error_image_edit_failed">A kép nem szerkeszthető.</string>
<string name="error_loading_account_details">Nem sikerült betölteni a fiókadatokat</string>
</resources>

View File

@ -525,4 +525,16 @@
<string name="notification_sign_up_description">Tilkynningar um nýja notendur</string>
<string name="notification_update_name">Breytingar á færslum</string>
<string name="notification_update_description">Tilkynningar þegar færslum sem þú hefur átt við er breytt</string>
<string name="dialog_push_notification_migration_other_accounts">Þú hefur skráð þig aftur inn í fyrirliggjandi aðganginn þinn til þess að veita heimild fyrir áskrift að ýti-tilkynningum í Tusky. Aftur á móti ertu með aðra aðganga sem ekki hafa verið yfirfærðir á þennan hátt. Skiptu yfir í þá og skráðu þig þar inn aftur til að virkja stuðning við tilkynningar í gegnum UnifiedPush.</string>
<string name="account_date_joined">Skráði sig %1$s</string>
<string name="tips_push_notification_migration">Skrá aftur inn alla aðganga til að virkja stuðning við ýti-tilkynningar.</string>
<string name="dialog_push_notification_migration">Til þess að geta sent ýti-tilkynningar í gegnum UnifiedPush, þarf Tusky heimild til að gerast áskrifandi að tilkynningum á Mastodon-netþjóninum þínum. Þetta krefst þess að skráð sé inn aftur til að breyta vægi OAuth-heimilda sem Tusky er úthlutað. Notaðu endurinnskráninguna hérna eða í kjörstillingum aðgangsins þíns til að varðveita öll drögin þín og skyndiminni á tækinu.</string>
<string name="title_login">Skrá inn</string>
<string name="status_count_one_plus">1+</string>
<string name="error_could_not_load_login_page">Gat ekki lesið innskráningarsíðuna.</string>
<string name="action_edit_image">Breyta mynd</string>
<string name="saving_draft">Vista drög…</string>
<string name="title_migration_relogin">Skráðu aftur inn fyrir ýti-tilkynningar</string>
<string name="action_dismiss">Hunsa</string>
<string name="action_details">Nánar</string>
</resources>

View File

@ -30,7 +30,7 @@
<string name="title_posts_with_replies">Con risposte</string>
<string name="title_posts_pinned">Fissati</string>
<string name="title_follows">Seguiti</string>
<string name="title_followers">Seguono</string>
<string name="title_followers">Seguaci</string>
<string name="title_favourites">Preferiti</string>
<string name="title_mutes">Utenti silenziati</string>
<string name="title_blocks">Utenti bloccati</string>
@ -39,7 +39,7 @@
<string name="title_drafts">Bozze</string>
<string name="title_licenses">Licenze</string>
<string name="post_username_format">\@%s</string>
<string name="post_boosted_format">%s ha boostato</string>
<string name="post_boosted_format">%s ha condiviso</string>
<string name="post_sensitive_media_title">Contenuto sensibile</string>
<string name="post_media_hidden_title">Media nascosto</string>
<string name="post_sensitive_media_directions">Clicca per visualizzare</string>
@ -49,15 +49,15 @@
<string name="post_content_show_less">Riduci</string>
<string name="message_empty">Qui non c\'è nulla.</string>
<string name="footer_empty">Qui non c\'è nulla. Trascina verso il basso per aggiornare!</string>
<string name="notification_reblog_format">%s ha boostato il tuo post</string>
<string name="notification_reblog_format">%s ha condiviso il tuo post</string>
<string name="notification_favourite_format">%s ha messo il tuo post nei preferiti</string>
<string name="notification_follow_format">%s ti ha seguito</string>
<string name="report_username_format">Segnala @%s</string>
<string name="report_comment_hint">Commenti aggiuntivi?</string>
<string name="action_quick_reply">Risposta veloce</string>
<string name="action_reply">Rispondi</string>
<string name="action_reblog">Boosta</string>
<string name="action_unreblog">Rimuovi boost</string>
<string name="action_reblog">Condividi</string>
<string name="action_unreblog">Rimuovi condivisione</string>
<string name="action_favourite">Aggiungi ai preferiti</string>
<string name="action_unfavourite">Rimuovi preferito</string>
<string name="action_more">Di più</string>
@ -69,8 +69,8 @@
<string name="action_unfollow">Smetti di seguire</string>
<string name="action_block">Blocca</string>
<string name="action_unblock">Sblocca</string>
<string name="action_hide_reblogs">Nascondi boost</string>
<string name="action_show_reblogs">Mostra boost</string>
<string name="action_hide_reblogs">Nascondi condivisioni</string>
<string name="action_show_reblogs">Mostra condivisioni</string>
<string name="action_report">Segnala</string>
<string name="action_delete">Elimina</string>
<string name="action_send">TOOT</string>
@ -109,8 +109,8 @@
<string name="action_links">Collegamenti</string>
<string name="action_mentions">Menzioni</string>
<string name="action_hashtags">Hashtag</string>
<string name="action_open_reblogger">Vai all\'autore del boost</string>
<string name="action_open_reblogged_by">Mostra boost</string>
<string name="action_open_reblogger">Vai all\'autore della condivisione</string>
<string name="action_open_reblogged_by">Mostra condivisioni</string>
<string name="action_open_faved_by">Mostra preferiti</string>
<string name="title_hashtags_dialog">Hashtag</string>
<string name="title_mentions_dialog">Menzioni</string>
@ -153,8 +153,8 @@
<string name="dialog_message_cancel_follow_request">Revocare la richiesta di seguire?</string>
<string name="dialog_unfollow_warning">Smettere di seguire questo account?</string>
<string name="dialog_delete_post_warning">Eliminare questo post\?</string>
<string name="visibility_public">Pubblico: visibile sulla timeline pubblica</string>
<string name="visibility_unlisted">Non in elenco: non visibile sulla timeline pubblica e locale</string>
<string name="visibility_public">Pubblico: visibile sulle timeline pubbliche</string>
<string name="visibility_unlisted">Non in elenco: non visibile sulle timeline pubbliche</string>
<string name="visibility_private">Solo follower: visibile solo dai tuoi follower</string>
<string name="visibility_direct">Diretto: visibile solo agli utenti menzionati</string>
<string name="pref_title_edit_notification_settings">Notifiche</string>
@ -166,7 +166,7 @@
<string name="pref_title_notification_filters">Notificami quando</string>
<string name="pref_title_notification_filter_mentions">vengo menzionato</string>
<string name="pref_title_notification_filter_follows">vengo seguito</string>
<string name="pref_title_notification_filter_reblogs">i miei post vengono boostati</string>
<string name="pref_title_notification_filter_reblogs">i miei post vengono condivisi</string>
<string name="pref_title_notification_filter_favourites">i miei post vengono messi nei preferiti</string>
<string name="pref_title_appearance_settings">Aspetto</string>
<string name="pref_title_app_theme">Tema dell\'app</string>
@ -183,7 +183,7 @@
<string name="pref_title_language">Lingua</string>
<string name="pref_title_post_filter">Filtraggio della timeline</string>
<string name="pref_title_post_tabs">Schede</string>
<string name="pref_title_show_boosts">Mostra boost</string>
<string name="pref_title_show_boosts">Mostra condivisioni</string>
<string name="pref_title_show_replies">Mostra risposte</string>
<string name="pref_title_show_media_preview">Mostra anteprime media</string>
<string name="pref_title_proxy_settings">Proxy</string>
@ -208,8 +208,8 @@
<string name="notification_mention_descriptions">Notifiche di quando vieni menzionato da qualcuno</string>
<string name="notification_follow_name">Nuovi follower</string>
<string name="notification_follow_description">Notifiche su nuovi follower</string>
<string name="notification_boost_name">Boost</string>
<string name="notification_boost_description">Notifiche sui tuoi post che vengono boostati</string>
<string name="notification_boost_name">Condivisioni</string>
<string name="notification_boost_description">Notifiche sui tuoi post che vengono condivisi</string>
<string name="notification_favourite_name">Preferiti</string>
<string name="notification_favourite_description">Notifiche sui tuoi post che vengono segnati come preferiti</string>
<string name="notification_mention_format">%s ti ha menzionato</string>
@ -312,8 +312,8 @@
<string name="download_failed">Download fallito</string>
<string name="profile_badge_bot_text">Bot</string>
<string name="account_moved_description">%1$s si è spostato su:</string>
<string name="reblog_private">Boost con la visibilità del post di origine</string>
<string name="unreblog_private">Annulla boost</string>
<string name="reblog_private">Condividi con la visibilità del post originale</string>
<string name="unreblog_private">Annulla condivisione</string>
<string name="license_description">Yuito contiene codice e risorse dai seguenti progetti open source:</string>
<string name="license_apache_2">Licenziata sotto la Licenza Apache (copia sotto)</string>
<string name="license_cc_by_4">CC-BY 4.0</string>
@ -334,7 +334,7 @@
<item quantity="one">&lt;b&gt;%s&lt;/b&gt; Boost</item>
<item quantity="other">&lt;b&gt;%s&lt;/b&gt; Boost</item>
</plurals>
<string name="title_reblogged_by">Boostato da</string>
<string name="title_reblogged_by">Condiviso da</string>
<string name="title_favourited_by">Aggiunto ai preferiti da</string>
<string name="conversation_1_recipients">%1$s</string>
<string name="conversation_2_recipients">%1$s e %2$s</string>
@ -402,7 +402,7 @@
<string name="select_list_title">Scegli lista</string>
<string name="list">Lista</string>
<string name="compose_preview_image_description">Azioni per l\'immagine %s</string>
<string name="poll_ended_voted">Un sondaggio che hai votato si è concluso</string>
<string name="poll_ended_voted">Un sondaggio in cui hai votato si è concluso</string>
<string name="poll_ended_created">Un sondaggio che hai creato si è concluso</string>
<plurals name="poll_timespan_days">
<item quantity="one">%d giorno rimasto</item>
@ -470,7 +470,7 @@
<string name="account_note_saved">Salvato!</string>
<string name="account_note_hint">La tua nota privata su questo account</string>
<string name="pref_title_hide_top_toolbar">Nascondi il titolo della barra degli strumenti in alto</string>
<string name="pref_title_confirm_reblogs">Mostra la finestra di conferma prima di boostare</string>
<string name="pref_title_confirm_reblogs">Mostra la finestra di conferma prima di condividere</string>
<string name="pref_title_show_cards_in_timelines">Mostra le anteprime dei collegamenti nelle timelines</string>
<string name="warning_scheduling_interval">Mastodon ha un intervallo di programmazione minimo di 5 minuti.</string>
<string name="no_announcements">Non ci sono annunci.</string>
@ -488,7 +488,7 @@
<string name="pref_title_notification_filter_follow_requests">mi viene richiesto di seguirmi</string>
<string name="wellbeing_hide_stats_profile">Nascondi statistiche quantitative sui profili</string>
<string name="wellbeing_hide_stats_posts">Nascondi le statistiche quantitative sui post</string>
<string name="limit_notifications">Limita le notifiche dalla timeline</string>
<string name="limit_notifications">Limita notifiche riguardo statistiche quantitative</string>
<string name="review_notifications">Rivedi le notifiche</string>
<string name="pref_title_wellbeing_mode">Benessere</string>
<string name="notification_subscription_description">Notifiche di nuovi post di qualcuno a cui sei iscritto</string>
@ -515,13 +515,13 @@
<string name="action_delete_conversation">Elimina conversazione</string>
<string name="wellbeing_mode_notice">Alcune informazioni che potrebbero influenzare il tuo benessere mentale saranno nascoste. Questo include:
\n
\n - Notifiche riguardo a Preferiti/Boost/Following
\n - Conteggio dei Preferiti/Boost nei post
\n - Notifiche riguardo a Preferiti/Condivisioni/Following
\n - Conteggio dei Preferiti/Condivisioni nei post
\n - Statistiche riguardo a Preferiti/Post nei profili
\n
\n Le notifiche push non saranno influenzate, ma puoi modificare le tue impostazioni delle notifiche manualmente.</string>
<string name="action_unbookmark">Rimuovi segnalibro</string>
<string name="pref_title_confirm_favourites">Chiedi conferma prima di boostare</string>
<string name="pref_title_confirm_favourites">Chiedi conferma prima di condividere</string>
<string name="duration_14_days">14 giorni</string>
<string name="duration_30_days">30 giorni</string>
<string name="duration_60_days">60 giorni</string>
@ -540,4 +540,8 @@
<string name="notification_update_name">Modifiche ai post</string>
<string name="notification_update_description">Notifiche di quando i post con cui hai interagito vengono modificati</string>
<string name="error_could_not_load_login_page">Non è stato possibile caricare la pagina di login.</string>
<string name="action_edit_image">Modifica immagine</string>
<string name="saving_draft">Salvataggio bozza…</string>
<string name="action_dismiss">Scartare</string>
<string name="action_details">Dettagli</string>
</resources>

View File

@ -527,4 +527,16 @@
<string name="notification_update_description">Varslinger når et innlegg du har hatt en interaksjon med er redigert</string>
<string name="title_login">Innlogging</string>
<string name="error_could_not_load_login_page">Klarte ikke å laste innloggingssiden.</string>
<string name="title_migration_relogin">Logg inn på nytt for pushvarsler</string>
<string name="action_dismiss">Avvis</string>
<string name="action_details">Detaljer</string>
<string name="account_date_joined">Ble med %1$s</string>
<string name="tips_push_notification_migration">Logg inn all konti på nytt for å skru på pushvarsler.</string>
<string name="dialog_push_notification_migration">For å kunne sende pushvarsler via UnifiedPush trenger Tusky tillatelse til å abonnere på varsler på Mastodon-serveren. Dette krever at du logger inn på nytt. Ved å bruke muligheten til å logge inn på nytt her eller i kontoinstillinger vil alle lokale kladder være tilgjengelig også etter at du har logget inn på nytt.</string>
<string name="dialog_push_notification_migration_other_accounts">Du har logget inn på nytt for å tillate Tusky til å sende pushvarsler, men du har fortsatt andre konti som ikke har fått den nødvendige tillatelsen. Bytt til dem og logg inn på nytt på samme måte for å skru på støtte for pushvarsler via UnifiedPush.</string>
<string name="saving_draft">Lagrer kladd…</string>
<string name="status_count_one_plus">1+</string>
<string name="action_edit_image">Rediger bilde</string>
<string name="error_image_edit_failed">Bildet kunne ikke redigeres.</string>
<string name="error_loading_account_details">Lasting av kontodetaljer feilet</string>
</resources>

View File

@ -65,10 +65,10 @@
<string name="title_announcements">Anúncios</string>
<string name="title_licenses">Licenças</string>
<string name="post_username_format">\@%s</string>
<string name="post_boosted_format">%s fez boost</string>
<string name="post_boosted_format">%s deu boost</string>
<string name="message_empty">Nada aqui.</string>
<string name="footer_empty">Nada para ver aqui. Arraste para baixo para atualizar!</string>
<string name="notification_reblog_format">%s fez boost ao seu toot</string>
<string name="notification_reblog_format">%s deu boost ao seu toot</string>
<string name="notification_favourite_format">%s adicionou o seu toot aos favoritos</string>
<string name="notification_follow_format">%s está a seguir-te</string>
<string name="notification_follow_request_format">%s pediu para te seguir</string>
@ -134,7 +134,7 @@
<string name="action_open_drawer">Abrir menu</string>
<string name="action_search">Pesquisar</string>
<string name="action_access_drafts">Rascunhos</string>
<string name="action_access_scheduled_posts">Toots agendados</string>
<string name="action_access_scheduled_posts">Toots Agendados</string>
<string name="action_toggle_visibility">Privacidade do toot</string>
<string name="action_content_warning">Aviso de conteúdo</string>
<string name="action_emoji_keyboard">Teclado de emojis</string>
@ -325,7 +325,6 @@
<string name="action_lists">Listas</string>
<string name="error_rename_list">Não foi possível renomear a lista</string>
<string name="title_lists">Listas</string>
<string name="error_create_list">Não foi possível criar a lista</string>
<string name="error_delete_list">Não foi possível apagar a lista</string>
<string name="action_create_list">Criar uma lista</string>

View File

@ -550,4 +550,15 @@
<string name="title_login">Вхід</string>
<string name="error_could_not_load_login_page">Не вдалося завантажити сторінку входу.</string>
<string name="saving_draft">Збереження чернетки…</string>
<string name="action_dismiss">Відхилити</string>
<string name="action_details">Подробиці</string>
<string name="title_migration_relogin">Увійдіть повторно, щоб отримувати push-сповіщення</string>
<string name="tips_push_notification_migration">Увійдіть повторно до всіх облікових записів, щоб увімкнути підтримку push-сповіщень.</string>
<string name="dialog_push_notification_migration">Щоб використовувати push-сповіщення через UnifiedPush, Tusky потребує дозволу стежити за сповіщеннями на вашому сервері Mastodon. Це вимагає повторного входу, щоб змінити області OAuth, надані Tusky. Використання параметра повторного входу тут або в налаштуваннях облікового запису збереже всі ваші локальні чернетки та кеш.</string>
<string name="dialog_push_notification_migration_other_accounts">Ви повторно увійшли до свого поточного облікового запису, щоб надати дозвіл на стеження Tusky. Однак у вас все ще є інші облікові записи, які не мігрували таким чином. Перейдіть до них і повторно увійдіть до них по одному, щоб забезпечити підтримку UnifiedPush сповіщень.</string>
<string name="account_date_joined">Приєднується %1$s</string>
<string name="action_edit_image">Редагувати зображення</string>
<string name="status_count_one_plus">1+</string>
<string name="error_image_edit_failed">Неможливо редагувати зображення.</string>
<string name="error_loading_account_details">Не вдалося завантажити подробиці облікового запису</string>
</resources>

View File

@ -517,4 +517,15 @@
<string name="title_login">Đăng nhập</string>
<string name="error_could_not_load_login_page">Không thể tải trang đăng nhập.</string>
<string name="saving_draft">Đang lưu nháp…</string>
<string name="action_dismiss">Bỏ qua</string>
<string name="title_migration_relogin">Đăng nhập lại để hiện thông báo đẩy</string>
<string name="action_details">Chi tiết</string>
<string name="account_date_joined">Tham gia vào %1$s</string>
<string name="tips_push_notification_migration">Đăng nhập lại tất cả tài khoản để kích hoạt thông báo đẩy.</string>
<string name="dialog_push_notification_migration_other_accounts">Bạn đã đăng nhập lại vào tài khoản hiện tại của mình để cấp quyền thông báo đẩy cho Tusky. Tuy nhiên, bạn vẫn có các tài khoản khác chưa kích hoạt thông báo đẩy theo cách này. Chuyển sang chúng và đăng nhập từng cái một để cho phép hỗ trợ thông báo UnifiedPush.</string>
<string name="dialog_push_notification_migration">Để sử dụng thông báo đẩy qua UnifiedPush, Tusky cần có quyền đăng ký thông báo trên máy chủ Mastodon của bạn. Bạn hãy thoát ra rồi đăng nhập lại để thay đổi phạm vi OAuth được cấp cho Tusky. Sử dụng đăng nhập lại ở đây hoặc trong cài đặt Tài khoản sẽ bảo toàn tất cả các tút nháp và bộ nhớ đệm trên điện thoại của bạn.</string>
<string name="status_count_one_plus">1+</string>
<string name="action_edit_image">Sửa ảnh</string>
<string name="error_image_edit_failed">Hình ảnh này không thể sửa.</string>
<string name="error_loading_account_details">Không thể tải thông tin tài khoản</string>
</resources>

View File

@ -31,7 +31,7 @@
<string name="title_posts_pinned">已置顶</string>
<string name="title_follows">正在关注</string>
<string name="title_followers">关注者</string>
<string name="title_favourites">收藏</string>
<string name="title_favourites">喜欢</string>
<string name="title_mutes">被隐藏的用户</string>
<string name="title_blocks">被屏蔽的用户</string>
<string name="title_follow_requests">关注请求</string>
@ -50,7 +50,7 @@
<string name="message_empty">还没有内容。</string>
<string name="footer_empty">还没有内容,向下拉动即可刷新!</string>
<string name="notification_reblog_format">%s 转嘟了你的嘟文</string>
<string name="notification_favourite_format">%s 收藏了你的嘟文</string>
<string name="notification_favourite_format">%s 喜欢了你的嘟文</string>
<string name="notification_follow_format">%s 关注了你</string>
<string name="report_username_format">举报 @%s</string>
<string name="report_comment_hint">是否有更多信息需报告?</string>
@ -58,8 +58,8 @@
<string name="action_reply">回复</string>
<string name="action_reblog">转嘟</string>
<string name="action_unreblog">取消转嘟</string>
<string name="action_favourite">收藏</string>
<string name="action_unfavourite">取消收藏</string>
<string name="action_favourite">喜欢</string>
<string name="action_unfavourite">取消喜欢</string>
<string name="action_more">更多</string>
<string name="action_compose">发表嘟文</string>
<string name="action_login">登录 Mastodon 帐号</string>
@ -81,7 +81,7 @@
<string name="action_view_profile">个人资料</string>
<string name="action_view_preferences">设置</string>
<string name="action_view_account_preferences">帐户设置</string>
<string name="action_view_favourites">收藏</string>
<string name="action_view_favourites">喜欢</string>
<string name="action_view_mutes">被隐藏的用户</string>
<string name="action_view_blocks">被屏蔽的用户</string>
<string name="action_view_follow_requests">关注请求</string>
@ -112,7 +112,7 @@
<string name="action_hashtags">话题</string>
<string name="action_open_reblogger">打开转嘟用户主页</string>
<string name="action_open_reblogged_by">显示转嘟</string>
<string name="action_open_faved_by">显示收藏</string>
<string name="action_open_faved_by">显示喜欢</string>
<string name="title_hashtags_dialog">话题</string>
<string name="title_mentions_dialog">提及</string>
<string name="title_links_dialog">链接</string>
@ -171,7 +171,7 @@
<string name="pref_title_notification_filter_mentions">被提及</string>
<string name="pref_title_notification_filter_follows">有新的关注者</string>
<string name="pref_title_notification_filter_reblogs">嘟文被转嘟</string>
<string name="pref_title_notification_filter_favourites">嘟文被收藏</string>
<string name="pref_title_notification_filter_favourites">嘟文被喜欢</string>
<string name="pref_title_notification_filter_poll">投票已结束</string>
<string name="pref_title_appearance_settings">外观</string>
<string name="pref_title_app_theme">应用主题</string>
@ -215,8 +215,8 @@
<string name="notification_follow_description">当有用户关注我时</string>
<string name="notification_boost_name">转嘟</string>
<string name="notification_boost_description">当我的嘟文被转发时通知</string>
<string name="notification_favourite_name">收藏</string>
<string name="notification_favourite_description">当有用户收藏了我的嘟文时通知</string>
<string name="notification_favourite_name">喜欢</string>
<string name="notification_favourite_description">当有用户喜欢了我的嘟文时</string>
<string name="notification_poll_name">投票</string>
<string name="notification_poll_description">当我参与的投票结束时</string>
<string name="notification_mention_format">%s 提及了你</string>
@ -337,13 +337,13 @@
<string name="unpin_action">取消置顶</string>
<string name="pin_action">置顶</string>
<plurals name="favs">
<item quantity="other">&lt;b&gt;%1$s&lt;/b&gt;收藏</item>
<item quantity="other">&lt;b&gt;%1$s&lt;/b&gt;喜欢</item>
</plurals>
<plurals name="reblogs">
<item quantity="other">&lt;b&gt;%s&lt;/b&gt; 次转嘟</item>
</plurals>
<string name="title_reblogged_by">转嘟</string>
<string name="title_favourited_by">收藏</string>
<string name="title_favourited_by">喜欢</string>
<string name="conversation_1_recipients">%1$s</string>
<string name="conversation_2_recipients">%1$s 和 %2$s</string>
<string name="conversation_more_recipients">%1$s%2$s 和 %3$d 等人</string>
@ -354,7 +354,7 @@
<string name="description_post_cw">内容警告:%s</string>
<string name="description_post_media_no_description_placeholder">没有描述信息</string>
<string name="description_post_reblogged">被转嘟</string>
<string name="description_post_favourited">收藏</string>
<string name="description_post_favourited">喜欢</string>
<string name="description_visiblity_public">
公开
</string>
@ -495,8 +495,8 @@
<string name="limit_notifications">限制时间线通知</string>
<string name="wellbeing_mode_notice">一些可能影响您精神状态的信息将被隐藏,这些信息包括:
\n
\n - 收藏、转发、关注通知
\n - 收藏、转发数
\n - 喜欢、转发、关注通知
\n - 喜欢、转发数
\n - 账号的已关注数量、嘟文数量
\n
\n 推送通知不会被影响,但可以在通知设置中手动禁用。</string>
@ -516,7 +516,7 @@
<string name="follow_requests_info">即使您的账号未上锁,管理员 %1$s 认为您可能需要手动处理来自这些账号的关注请求。</string>
<string name="dialog_delete_conversation_warning">删除此对话吗?</string>
<string name="action_delete_conversation">删除对话</string>
<string name="pref_title_confirm_favourites">收藏前显示确认对话框</string>
<string name="pref_title_confirm_favourites">喜欢前显示确认对话框</string>
<string name="action_unbookmark">删除书签</string>
<string name="duration_30_days">30 天</string>
<string name="duration_60_days">60 天</string>
@ -536,4 +536,15 @@
<string name="notification_update_description">当你进行过互动的嘟文被编辑时发出通知</string>
<string name="error_could_not_load_login_page">无法加载登录页。</string>
<string name="saving_draft">正在保存草稿…</string>
<string name="title_migration_relogin">重新登陆以启用通知推送</string>
<string name="action_dismiss">不理会</string>
<string name="action_details">详情</string>
<string name="dialog_push_notification_migration_other_accounts">你已重新登录当前账户,向 Tusky 授予推送订阅权限。但是,你仍然有其他没有以这种方式迁移的账户。切换到它们,逐个重新登录,以启用 UnifiedPush 通知支持。</string>
<string name="account_date_joined">加入于%1$s</string>
<string name="tips_push_notification_migration">重新登录所有账户来启用推送通知支持。</string>
<string name="dialog_push_notification_migration">为了通过 UnifiedPush 使用推送通知Tusky 需要订阅你 Mastodon 服务器通知的权限。这需要重新登录来更改授予 Tusky 的 OAuth 作用域。使用此处或账户首选项中“重新登录”选项将保留你所有的本地草稿和缓存。</string>
<string name="status_count_one_plus">1+</string>
<string name="action_edit_image">编辑图片</string>
<string name="error_image_edit_failed">无法编辑图片。</string>
<string name="error_loading_account_details">加载账户详情失败</string>
</resources>

View File

@ -31,7 +31,7 @@
<string name="title_posts_pinned">已置顶</string>
<string name="title_follows">正在关注</string>
<string name="title_followers">关注者</string>
<string name="title_favourites">收藏</string>
<string name="title_favourites">喜欢</string>
<string name="title_mutes">被隐藏的用户</string>
<string name="title_blocks">被屏蔽的用户</string>
<string name="title_follow_requests">关注请求</string>
@ -50,7 +50,7 @@
<string name="message_empty">还没有内容</string>
<string name="footer_empty">还没有内容,向下拉动即可刷新</string>
<string name="notification_reblog_format">%s 转嘟了你的嘟文</string>
<string name="notification_favourite_format">%s 收藏了你的嘟文</string>
<string name="notification_favourite_format">%s 喜欢了你的嘟文</string>
<string name="notification_follow_format">%s 关注了你</string>
<string name="report_username_format">报告用户 @%s 的滥用行为</string>
<string name="report_comment_hint">报告更多信息</string>
@ -58,8 +58,8 @@
<string name="action_reply">回复</string>
<string name="action_reblog">转嘟</string>
<string name="action_unreblog">取消转嘟</string>
<string name="action_favourite">收藏</string>
<string name="action_unfavourite">取消收藏</string>
<string name="action_favourite">喜欢</string>
<string name="action_unfavourite">取消喜欢</string>
<string name="action_more">更多</string>
<string name="action_compose">新嘟文</string>
<string name="action_login">登录 Mastodon 帐号</string>
@ -81,7 +81,7 @@
<string name="action_view_profile">个人资料</string>
<string name="action_view_preferences">设置</string>
<string name="action_view_account_preferences">帐户设置</string>
<string name="action_view_favourites">收藏</string>
<string name="action_view_favourites">喜欢</string>
<string name="action_view_mutes">被隐藏的用户</string>
<string name="action_view_blocks">被屏蔽的用户</string>
<string name="action_view_follow_requests">关注请求</string>
@ -112,7 +112,7 @@
<string name="action_hashtags">话题</string>
<string name="action_open_reblogger">打开转嘟用户主页</string>
<string name="action_open_reblogged_by">显示转嘟</string>
<string name="action_open_faved_by">显示收藏</string>
<string name="action_open_faved_by">显示喜欢</string>
<string name="title_hashtags_dialog">话题</string>
<string name="title_mentions_dialog">提及</string>
<string name="title_links_dialog">链接</string>
@ -168,7 +168,7 @@
<string name="pref_title_notification_filter_mentions">被提及</string>
<string name="pref_title_notification_filter_follows">有新的关注者</string>
<string name="pref_title_notification_filter_reblogs">嘟文被转嘟</string>
<string name="pref_title_notification_filter_favourites">嘟文被收藏</string>
<string name="pref_title_notification_filter_favourites">嘟文被喜欢</string>
<string name="pref_title_notification_filter_poll">投票已结束</string>
<string name="pref_title_appearance_settings">外观</string>
<string name="pref_title_app_theme">应用主题</string>
@ -212,8 +212,8 @@
<string name="notification_follow_description">当有用户关注我时</string>
<string name="notification_boost_name">转嘟</string>
<string name="notification_boost_description">当有用户转嘟了我的嘟文时</string>
<string name="notification_favourite_name">收藏</string>
<string name="notification_favourite_description">当有用户收藏了我的嘟文时</string>
<string name="notification_favourite_name">喜欢</string>
<string name="notification_favourite_description">当有用户喜欢了我的嘟文时</string>
<string name="notification_poll_name">投票</string>
<string name="notification_poll_description">当我参与的投票结束时</string>
<string name="notification_mention_format">%s 提及了你</string>
@ -333,13 +333,13 @@
<string name="unpin_action">取消置顶</string>
<string name="pin_action">置顶</string>
<plurals name="favs">
<item quantity="other">&lt;b&gt;%1$s&lt;/b&gt; 次收藏</item>
<item quantity="other"><b>%1$s</b> 次喜欢</item>
</plurals>
<plurals name="reblogs">
<item quantity="other">&lt;b&gt;%s&lt;/b&gt; 次转嘟</item>
</plurals>
<string name="title_reblogged_by">转嘟</string>
<string name="title_favourited_by">收藏</string>
<string name="title_favourited_by">喜欢</string>
<string name="conversation_1_recipients">%1$s</string>
<string name="conversation_2_recipients">%1$s 和 %2$s</string>
<string name="conversation_more_recipients">%1$s, %2$s 和 %3$d 等人</string>
@ -358,9 +358,7 @@
<string name="description_post_reblogged">
被转嘟
</string>
<string name="description_post_favourited">
被收藏
</string>
<string name="description_post_favourited">被喜欢</string>
<string name="description_visiblity_public">
公开
</string>

View File

@ -50,7 +50,7 @@
<string name="message_empty">沒有內容。</string>
<string name="footer_empty">還沒有內容,向下拉動即可重新整理!</string>
<string name="notification_reblog_format">%s 轉嘟了你的嘟文</string>
<string name="notification_favourite_format">%s 收藏了你的嘟文</string>
<string name="notification_favourite_format">%s 最愛了你的嘟文</string>
<string name="notification_follow_format">%s 關注了你</string>
<string name="report_username_format">檢舉使用者 @%s 的濫用行為</string>
<string name="report_comment_hint">更多評論?</string>
@ -58,8 +58,8 @@
<string name="action_reply">回覆</string>
<string name="action_reblog">轉嘟</string>
<string name="action_unreblog">取消轉嘟</string>
<string name="action_favourite">收藏</string>
<string name="action_unfavourite">取消收藏</string>
<string name="action_favourite">最愛</string>
<string name="action_unfavourite">取消最愛</string>
<string name="action_more">更多</string>
<string name="action_compose">撰寫嘟文</string>
<string name="action_login">登入 Mastodon 帳號</string>
@ -81,7 +81,7 @@
<string name="action_view_profile">個人資料</string>
<string name="action_view_preferences">設定</string>
<string name="action_view_account_preferences">帳戶設定</string>
<string name="action_view_favourites">我的收藏</string>
<string name="action_view_favourites">我的最愛</string>
<string name="action_view_mutes">被靜音的使用者</string>
<string name="action_view_blocks">被封鎖的使用者</string>
<string name="action_view_follow_requests">關注請求</string>
@ -171,7 +171,7 @@
<string name="pref_title_notification_filter_mentions">被提及</string>
<string name="pref_title_notification_filter_follows">有新的關注者</string>
<string name="pref_title_notification_filter_reblogs">嘟文被轉嘟</string>
<string name="pref_title_notification_filter_favourites">嘟文被加入收藏</string>
<string name="pref_title_notification_filter_favourites">嘟文被加入最愛</string>
<string name="pref_title_notification_filter_poll">投票已結束</string>
<string name="pref_title_appearance_settings">外觀</string>
<string name="pref_title_app_theme">佈景主題</string>
@ -215,8 +215,8 @@
<string name="notification_follow_description">當有使用者關注我時</string>
<string name="notification_boost_name">轉嘟</string>
<string name="notification_boost_description">當有使用者轉嘟了我的嘟文時</string>
<string name="notification_favourite_name">收藏</string>
<string name="notification_favourite_description">當有使用者把我的嘟文加入收藏</string>
<string name="notification_favourite_name">最愛</string>
<string name="notification_favourite_description">當有使用者把我的嘟文加入最愛</string>
<string name="notification_poll_name">投票</string>
<string name="notification_poll_description">當我參與的投票結束時</string>
<string name="notification_mention_format">%s 提及了你</string>
@ -335,13 +335,13 @@
<string name="unpin_action">取消置頂</string>
<string name="pin_action">置頂</string>
<plurals name="favs">
<item quantity="other">&lt;b&gt;%1$s&lt;/b&gt; 次收藏</item>
<item quantity="other"><b>%1$s</b> 次最愛</item>
</plurals>
<plurals name="reblogs">
<item quantity="other">&lt;b&gt;%s&lt;/b&gt; 次轉嘟</item>
</plurals>
<string name="title_reblogged_by">轉嘟</string>
<string name="title_favourited_by">收藏</string>
<string name="title_favourited_by">最愛</string>
<string name="conversation_1_recipients">%1$s</string>
<string name="conversation_2_recipients">%1$s 和 %2$s</string>
<string name="conversation_more_recipients">%1$s, %2$s 和 %3$d 等人</string>
@ -360,9 +360,7 @@
<string name="description_post_reblogged">
被轉嘟
</string>
<string name="description_post_favourited">
被收藏
</string>
<string name="description_post_favourited">被最愛</string>
<string name="description_visiblity_public">
公開
</string>
@ -444,8 +442,8 @@
<string name="review_notifications">檢查通知設定</string>
<string name="wellbeing_mode_notice">有些資訊可能會影響你的心理健康將會被隱藏。包括:
\n
\n- 收藏/轉嘟/關注 通知
\n- 收藏/轉嘟 數量
\n- 最愛/轉嘟/關注 通知
\n- 最愛/轉嘟 數量
\n- 關注/貼文 在個人頁面的狀態
\n
\n推播通知不會受到影響但你可以手動檢查你的通知設定。</string>

View File

@ -9,11 +9,13 @@
<string name="error_authorization_unknown">An unidentified authorization error occurred.</string>
<string name="error_authorization_denied">Authorization was denied.</string>
<string name="error_retrieving_oauth_token">Failed getting a login token.</string>
<string name="error_loading_account_details">Failed loading account details</string>
<string name="error_could_not_load_login_page">Could not load the login page.</string>
<string name="error_compose_character_limit">The post is too long!</string>
<string name="error_image_upload_size">The file must be less than 8MB.</string>
<string name="error_video_upload_size">Video files must be less than 40MB.</string>
<string name="error_audio_upload_size">Audio files must be less than 40MB.</string>
<string name="error_image_edit_failed">The image could not be edited.</string>
<string name="error_media_upload_type">That type of file cannot be uploaded.</string>
<string name="error_media_upload_opening">That file could not be opened.</string>
<string name="error_media_upload_permission">Permission to read media is required.</string>
@ -378,6 +380,7 @@
<string name="post_media_video">Video</string>
<string name="post_media_audio">Audio</string>
<string name="post_media_attachments">Attachments</string>
<string name="status_count_one_plus">1+</string>
<string name="state_follow_requested">Follow requested</string>
@ -434,6 +437,7 @@
<item quantity="other">Describe for visually impaired\n(%d character limit)</item>
</plurals>
<string name="action_set_caption">Set caption</string>
<string name="action_edit_image">Edit image</string>
<string name="action_remove">Remove</string>
<string name="lock_account_label">Lock account</string>
<string name="lock_account_label_description">Requires you to manually approve followers</string>

View File

@ -74,6 +74,7 @@ class BottomSheetActivityTest {
emojis = emptyList(),
reblogsCount = 0,
favouritesCount = 0,
repliesCount = 0,
reblogged = false,
favourited = false,
bookmarked = false,

View File

@ -19,6 +19,7 @@ import android.content.Intent
import android.os.Looper.getMainLooper
import android.widget.EditText
import androidx.test.ext.junit.runners.AndroidJUnit4
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.ComposeViewModel
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
@ -67,6 +68,8 @@ class ComposeActivityTest {
id = 1,
domain = instanceDomain,
accessToken = "token",
clientId = "id",
clientSecret = "secret",
isActive = true,
accountId = "1",
username = "username",
@ -95,12 +98,12 @@ class ComposeActivityTest {
}
apiMock = mock {
onBlocking { getCustomEmojis() } doReturn Result.success(emptyList())
onBlocking { getCustomEmojis() } doReturn NetworkResult.success(emptyList())
onBlocking { getInstance() } doReturn instanceResponseCallback?.invoke().let { instance ->
if (instance == null) {
Result.failure(Throwable())
NetworkResult.failure(Throwable())
} else {
Result.success(instance)
NetworkResult.success(instance)
}
}
}

View File

@ -166,6 +166,7 @@ class FilterTest {
emojis = emptyList(),
reblogsCount = 0,
favouritesCount = 0,
repliesCount = 0,
reblogged = false,
favourited = false,
bookmarked = false,

View File

@ -46,6 +46,8 @@ class CachedTimelineRemoteMediatorTest {
id = 1,
domain = "mastodon.example",
accessToken = "token",
clientId = "id",
clientSecret = "secret",
isActive = true
)
}

View File

@ -38,6 +38,8 @@ class NetworkTimelineRemoteMediatorTest {
id = 1,
domain = "mastodon.example",
accessToken = "token",
clientId = "id",
clientSecret = "secret",
isActive = true
)
}

View File

@ -29,6 +29,7 @@ fun mockStatus(id: String = "100") = Status(
emojis = emptyList(),
reblogsCount = 1,
favouritesCount = 2,
repliesCount = 3,
reblogged = false,
favourited = true,
bookmarked = true,

View File

@ -443,6 +443,7 @@ class TimelineDaoTest {
emojis = "emojis$statusId",
reblogsCount = 1 * statusId.toInt(),
favouritesCount = 2 * statusId.toInt(),
repliesCount = 3 * statusId.toInt(),
reblogged = even,
favourited = !even,
bookmarked = false,

View File

@ -0,0 +1,143 @@
package com.keylesspalace.tusky.network
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.mock
class InstanceSwitchAuthInterceptorTest {
private val mockWebServer = MockWebServer()
@Before
fun setup() {
mockWebServer.start()
}
@After
fun teardown() {
mockWebServer.shutdown()
}
@Test
fun `should make regular request when requested`() {
mockWebServer.enqueue(MockResponse())
val accountManager: AccountManager = mock {
on { activeAccount } doAnswer { null }
}
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
.build()
val request = Request.Builder()
.get()
.url(mockWebServer.url("/test"))
.build()
val response = okHttpClient.newCall(request).execute()
assertEquals(200, response.code)
}
@Test
fun `should make request to instance requested in special header`() {
mockWebServer.enqueue(MockResponse())
val accountManager: AccountManager = mock {
on { activeAccount } doAnswer {
AccountEntity(
id = 1,
domain = "test.domain",
accessToken = "fakeToken",
clientId = "fakeId",
clientSecret = "fakeSecret",
isActive = true
)
}
}
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
.build()
val request = Request.Builder()
.get()
.url("http://" + MastodonApi.PLACEHOLDER_DOMAIN + ":" + mockWebServer.port + "/test")
.header(MastodonApi.DOMAIN_HEADER, mockWebServer.hostName)
.build()
val response = okHttpClient.newCall(request).execute()
assertEquals(200, response.code)
assertNull(mockWebServer.takeRequest().getHeader("Authorization"))
}
@Test
fun `should make request to current instance when requested and user is logged in`() {
mockWebServer.enqueue(MockResponse())
val accountManager: AccountManager = mock {
on { activeAccount } doAnswer {
AccountEntity(
id = 1,
domain = mockWebServer.hostName,
accessToken = "fakeToken",
clientId = "fakeId",
clientSecret = "fakeSecret",
isActive = true
)
}
}
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
.build()
val request = Request.Builder()
.get()
.url("http://" + MastodonApi.PLACEHOLDER_DOMAIN + ":" + mockWebServer.port + "/test")
.build()
val response = okHttpClient.newCall(request).execute()
assertEquals(200, response.code)
assertEquals("Bearer fakeToken", mockWebServer.takeRequest().getHeader("Authorization"))
}
@Test
fun `should fail to make request when request to current instance is requested but no user is logged in`() {
mockWebServer.enqueue(MockResponse())
val accountManager: AccountManager = mock {
on { activeAccount } doAnswer { null }
}
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
.build()
val request = Request.Builder()
.get()
.url("http://" + MastodonApi.PLACEHOLDER_DOMAIN + "/test")
.build()
val response = okHttpClient.newCall(request).execute()
assertEquals(400, response.code)
assertEquals(0, mockWebServer.requestCount)
}
}

Some files were not shown because too many files have changed in this diff Show More