Merge remote-tracking branch 'tuskyapp/develop'

This commit is contained in:
kyori19 2022-04-13 00:59:06 +09:00
commit 1228f645a6
180 changed files with 5472 additions and 4186 deletions

View File

@ -7,7 +7,7 @@
Yuito is fork of [Tusky](https://github.com/tuskyapp/Tusky). Yuito is fork of [Tusky](https://github.com/tuskyapp/Tusky).
Tusky is a beautiful Android client for [Mastodon](https://github.com/tootsuite/mastodon). Mastodon is an ActivityPub federated social network. That means no single entity controls the whole network, rather, like e-mail, volunteers and organisations operate their own independent servers, users from which can all interact with each other seamlessly. Tusky is a beautiful Android client for [Mastodon](https://github.com/mastodon/mastodon). Mastodon is an ActivityPub federated social network. That means no single entity controls the whole network, rather, like e-mail, volunteers and organisations operate their own independent servers, users from which can all interact with each other seamlessly.
## Features ## Features
@ -15,14 +15,14 @@ Tusky is a beautiful Android client for [Mastodon](https://github.com/tootsuite/
- Most Mastodon APIs implemented - Most Mastodon APIs implemented
- Multi-Account support - Multi-Account support
- Dark, light and black themes with the possibility to auto-switch based on the time of day - Dark, light and black themes with the possibility to auto-switch based on the time of day
- Drafts - compose toots and save them for later - Drafts - compose posts and save them for later
- Choose between different emoji styles - Choose between different emoji styles
- Optimized for all screen sizes - Optimized for all screen sizes
- Completely open-source - no non-free dependencies like Google services - Completely open-source - no non-free dependencies like Google services
### Support ### Support
If you have any bug reports, feature requests or questions please open an issue or send us a toot at [@ars42525@odakyu.app](https://odakyu.app/@ars42525)! If you have any bug reports, feature requests or questions please open an issue or send us a message at [@ars42525@odakyu.app](https://odakyu.app/@ars42525)!
For translating Tusky into your language, visit https://weblate.tusky.app/ For translating Tusky into your language, visit https://weblate.tusky.app/

View File

@ -15,11 +15,11 @@ def getGitSha = {
} }
android { android {
compileSdkVersion 30 compileSdkVersion 31
defaultConfig { defaultConfig {
applicationId 'net.accelf.yuito' applicationId 'net.accelf.yuito'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 30 targetSdkVersion 31
versionCode 43 versionCode 43
versionName '4.1.3' versionName '4.1.3'
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -96,12 +96,12 @@ android {
} }
ext.coroutinesVersion = "1.6.0" ext.coroutinesVersion = "1.6.0"
ext.lifecycleVersion = "2.3.1" ext.lifecycleVersion = "2.4.1"
ext.roomVersion = '2.3.0' ext.roomVersion = '2.4.2'
ext.retrofitVersion = '2.9.0' ext.retrofitVersion = '2.9.0'
ext.okhttpVersion = '4.9.3' ext.okhttpVersion = '4.9.3'
ext.glideVersion = '4.12.0' ext.glideVersion = '4.13.1'
ext.daggerVersion = '2.40.5' ext.daggerVersion = '2.41'
ext.materialdrawerVersion = '8.4.5' ext.materialdrawerVersion = '8.4.5'
repositories { repositories {
@ -117,33 +117,35 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$coroutinesVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$coroutinesVersion"
implementation "androidx.core:core-ktx:1.5.0" implementation "androidx.core:core-ktx:1.7.0"
implementation "androidx.appcompat:appcompat:1.3.0" implementation "androidx.appcompat:appcompat:1.4.1"
implementation "androidx.fragment:fragment-ktx:1.3.4" implementation "androidx.fragment:fragment-ktx:1.4.1"
implementation "androidx.browser:browser:1.3.0" implementation "androidx.browser:browser:1.4.0"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation "androidx.recyclerview:recyclerview:1.2.1" implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.exifinterface:exifinterface:1.3.3" implementation "androidx.exifinterface:exifinterface:1.3.3"
implementation "androidx.cardview:cardview:1.0.0" implementation "androidx.cardview:cardview:1.0.0"
implementation "androidx.preference:preference-ktx:1.1.1" implementation "androidx.preference:preference-ktx:1.2.0"
implementation "androidx.sharetarget:sharetarget:1.1.0" implementation "androidx.sharetarget:sharetarget:1.2.0-rc01"
implementation "androidx.emoji:emoji:1.1.0" implementation "androidx.emoji:emoji:1.1.0"
implementation "androidx.emoji:emoji-appcompat:1.1.0" implementation "androidx.emoji:emoji-appcompat:1.1.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion"
implementation "androidx.constraintlayout:constraintlayout:2.1.2" implementation "androidx.constraintlayout:constraintlayout:2.1.3"
implementation "androidx.paging:paging-runtime-ktx:3.0.0" implementation "androidx.paging:paging-runtime-ktx:3.1.1"
implementation "androidx.viewpager2:viewpager2:1.0.0" implementation "androidx.viewpager2:viewpager2:1.0.0"
implementation "androidx.work:work-runtime:2.5.0" implementation "androidx.work:work-runtime:2.7.1"
implementation "androidx.room:room-ktx:$roomVersion" implementation "androidx.room:room-ktx:$roomVersion"
implementation "androidx.room:room-paging:$roomVersion"
implementation "androidx.room:room-rxjava3:$roomVersion" implementation "androidx.room:room-rxjava3:$roomVersion"
kapt "androidx.room:room-compiler:$roomVersion" kapt "androidx.room:room-compiler:$roomVersion"
implementation 'androidx.core:core-splashscreen:1.0.0-beta02'
implementation "com.google.android.material:material:1.4.0" implementation "com.google.android.material:material:1.5.0"
implementation "com.google.code.gson:gson:2.8.9" implementation "com.google.code.gson:gson:2.9.0"
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"
@ -158,14 +160,14 @@ dependencies {
implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion" implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion"
kapt "com.github.bumptech.glide:compiler:$glideVersion" kapt "com.github.bumptech.glide:compiler:$glideVersion"
implementation "com.github.penfeizhou.android.animation:glide-plugin:2.17.0" implementation "com.github.penfeizhou.android.animation:glide-plugin:2.20.0"
implementation "io.reactivex.rxjava3:rxjava:3.0.12" implementation "io.reactivex.rxjava3:rxjava:3.1.3"
implementation "io.reactivex.rxjava3:rxandroid:3.0.0" implementation "io.reactivex.rxjava3:rxandroid:3.0.0"
implementation "io.reactivex.rxjava3:rxkotlin:3.0.1" implementation "io.reactivex.rxjava3:rxkotlin:3.0.1"
implementation "com.uber.autodispose2:autodispose-androidx-lifecycle:2.0.0" implementation "com.uber.autodispose2:autodispose-androidx-lifecycle:2.1.1"
implementation "com.uber.autodispose2:autodispose:2.0.0" implementation "com.uber.autodispose2:autodispose:2.1.1"
implementation "com.google.dagger:dagger:$daggerVersion" implementation "com.google.dagger:dagger:$daggerVersion"
kapt "com.google.dagger:dagger-compiler:$daggerVersion" kapt "com.google.dagger:dagger-compiler:$daggerVersion"

View File

@ -0,0 +1,809 @@
{
"formatVersion": 1,
"database": {
"version": 31,
"identityHash": "a75615171612bdfc9e3d4201ebf6071a",
"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, `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)",
"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": "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
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_AccountEntity_domain_accountId",
"unique": true,
"columnNames": [
"domain",
"accountId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)"
}
],
"foreignKeys": []
},
{
"tableName": "InstanceEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))",
"fields": [
{
"fieldPath": "instance",
"columnName": "instance",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojiList",
"columnName": "emojiList",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "maximumTootCharacters",
"columnName": "maximumTootCharacters",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptions",
"columnName": "maxPollOptions",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptionLength",
"columnName": "maxPollOptionLength",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "minPollDuration",
"columnName": "minPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollDuration",
"columnName": "maxPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "charactersReservedPerUrl",
"columnName": "charactersReservedPerUrl",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"instance"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TimelineStatusEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authorServerId",
"columnName": "authorServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToAccountId",
"columnName": "inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogsCount",
"columnName": "reblogsCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favouritesCount",
"columnName": "favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "spoilerText",
"columnName": "spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogServerId",
"columnName": "reblogServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogAccountId",
"columnName": "reblogAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "expanded",
"columnName": "expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentCollapsed",
"columnName": "contentCollapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentShowing",
"columnName": "contentShowing",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
"unique": false,
"columnNames": [
"authorServerId",
"timelineUserId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
}
],
"foreignKeys": [
{
"table": "TimelineAccountEntity",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"authorServerId",
"timelineUserId"
],
"referencedColumns": [
"serverId",
"timelineUserId"
]
}
]
},
{
"tableName": "TimelineAccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localUsername",
"columnName": "localUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bot",
"columnName": "bot",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ConversationEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accounts",
"columnName": "accounts",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.id",
"columnName": "s_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.url",
"columnName": "s_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToId",
"columnName": "s_inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToAccountId",
"columnName": "s_inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.account",
"columnName": "s_account",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.content",
"columnName": "s_content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.createdAt",
"columnName": "s_createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.emojis",
"columnName": "s_emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.favouritesCount",
"columnName": "s_favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.favourited",
"columnName": "s_favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.bookmarked",
"columnName": "s_bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.sensitive",
"columnName": "s_sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.spoilerText",
"columnName": "s_spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.attachments",
"columnName": "s_attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.mentions",
"columnName": "s_mentions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.tags",
"columnName": "s_tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsible",
"columnName": "s_collapsible",
"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, 'a75615171612bdfc9e3d4201ebf6071a')"
]
}
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="notification_color">#19A341</color>
</resources>

View File

@ -21,37 +21,23 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/TuskyTheme" android:theme="@style/TuskyTheme"
android:usesCleartextTraffic="false"> android:usesCleartextTraffic="false">
<activity <activity
android:name=".SplashActivity" android:name=".components.login.LoginActivity"
android:theme="@style/SplashTheme"> android:windowSoftInputMode="adjustResize">
</activity>
<activity android:name=".components.login.LoginWebViewActivity" />
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize"
android:theme="@style/SplashTheme"
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/share_shortcuts" />
</activity>
<activity
android:name=".LoginActivity"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="${applicationId}"
android:scheme="@string/oauth_scheme" />
</intent-filter>
</activity>
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
@ -98,6 +84,9 @@
<meta-data <meta-data
android:name="android.service.chooser.chooser_target_service" android:name="android.service.chooser.chooser_target_service"
android:value="androidx.sharetarget.ChooserTargetServiceCompat" /> android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/share_shortcuts" />
</activity> </activity>
<activity <activity
@ -107,7 +96,6 @@
<activity <activity
android:name=".ViewThreadActivity" android:name=".ViewThreadActivity"
android:configChanges="orientation|screenSize" /> android:configChanges="orientation|screenSize" />
<activity android:name=".ViewTagActivity" />
<activity <activity
android:name=".ViewMediaActivity" android:name=".ViewMediaActivity"
android:theme="@style/TuskyBaseTheme" /> android:theme="@style/TuskyBaseTheme" />
@ -125,7 +113,8 @@
android:theme="@style/Base.Theme.AppCompat" /> android:theme="@style/Base.Theme.AppCompat" />
<activity <activity
android:name=".components.search.SearchActivity" android:name=".components.search.SearchActivity"
android:launchMode="singleTop"> android:launchMode="singleTop"
android:exported="false">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEARCH" /> <action android:name="android.intent.action.SEARCH" />
</intent-filter> </intent-filter>
@ -135,18 +124,18 @@
android:resource="@xml/searchable" /> android:resource="@xml/searchable" />
</activity> </activity>
<activity android:name=".ListsActivity" /> <activity android:name=".ListsActivity" />
<activity android:name=".ModalTimelineActivity" />
<activity android:name=".LicenseActivity" /> <activity android:name=".LicenseActivity" />
<activity android:name=".FiltersActivity" /> <activity android:name=".FiltersActivity" />
<activity <activity
android:name=".components.report.ReportActivity" android:name=".components.report.ReportActivity"
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" /> android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
<activity android:name=".components.instancemute.InstanceListActivity" /> <activity android:name=".components.instancemute.InstanceListActivity" />
<activity android:name=".components.scheduled.ScheduledTootActivity" /> <activity android:name=".components.scheduled.ScheduledStatusActivity" />
<activity android:name=".components.announcements.AnnouncementsActivity" /> <activity android:name=".components.announcements.AnnouncementsActivity" />
<activity android:name=".components.drafts.DraftsActivity" /> <activity android:name=".components.drafts.DraftsActivity" />
<receiver android:name=".receiver.NotificationClearBroadcastReceiver" /> <receiver android:name=".receiver.NotificationClearBroadcastReceiver"
android:exported="false" />
<receiver <receiver
android:name=".receiver.SendStatusBroadcastReceiver" android:name=".receiver.SendStatusBroadcastReceiver"
android:enabled="true" android:enabled="true"
@ -155,15 +144,17 @@
<service <service
android:name=".service.TuskyTileService" android:name=".service.TuskyTileService"
android:icon="@drawable/ic_tusky" android:icon="@drawable/ic_tusky"
android:label="Compose Toot" android:label="@string/tusky_compose_post_quicksetting_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE" android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:exported="true"
tools:targetApi="24"> tools:targetApi="24">
<intent-filter> <intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" /> <action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter> </intent-filter>
</service> </service>
<service android:name=".service.SendTootService" /> <service android:name=".service.SendStatusService"
android:exported="false" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
@ -177,10 +168,15 @@
<!-- disable automatic WorkManager initialization --> <!-- disable automatic WorkManager initialization -->
<provider <provider
android:name="androidx.work.impl.WorkManagerInitializer" android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.workmanager-init" android:authorities="${applicationId}.androidx-startup"
android:exported="false" android:exported="false"
tools:node="remove" /> tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<activity android:name="net.accelf.yuito.AccessTokenLoginActivity" /> <activity android:name="net.accelf.yuito.AccessTokenLoginActivity" />
</application> </application>

View File

@ -34,7 +34,7 @@ import com.keylesspalace.tusky.databinding.FragmentAccountsInListBinding
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.Either
@ -49,7 +49,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
private typealias AccountInfo = Pair<Account, Boolean> private typealias AccountInfo = Pair<TimelineAccount, Boolean>
class AccountsInListFragment : DialogFragment(), Injectable { class AccountsInListFragment : DialogFragment(), Injectable {
@ -168,21 +168,21 @@ class AccountsInListFragment : DialogFragment(), Injectable {
viewModel.deleteAccountFromList(listId, accountId) viewModel.deleteAccountFromList(listId, accountId)
} }
private fun onAddToList(account: Account) { private fun onAddToList(account: TimelineAccount) {
viewModel.addAccountToList(listId, account) viewModel.addAccountToList(listId, account)
} }
private object AccountDiffer : DiffUtil.ItemCallback<Account>() { private object AccountDiffer : DiffUtil.ItemCallback<TimelineAccount>() {
override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean { override fun areItemsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean {
return oldItem == newItem return oldItem.id == newItem.id
} }
override fun areContentsTheSame(oldItem: Account, newItem: Account): Boolean { override fun areContentsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean {
return oldItem.deepEquals(newItem) return oldItem == newItem
} }
} }
inner class Adapter : ListAdapter<Account, BindingHolder<ItemFollowRequestBinding>>(AccountDiffer) { inner class Adapter : ListAdapter<TimelineAccount, BindingHolder<ItemFollowRequestBinding>>(AccountDiffer) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowRequestBinding> { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowRequestBinding> {
val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false) val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false)
@ -209,12 +209,11 @@ class AccountsInListFragment : DialogFragment(), Injectable {
private object SearchDiffer : DiffUtil.ItemCallback<AccountInfo>() { private object SearchDiffer : DiffUtil.ItemCallback<AccountInfo>() {
override fun areItemsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean { override fun areItemsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean {
return oldItem == newItem return oldItem.first.id == newItem.first.id
} }
override fun areContentsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean { override fun areContentsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean {
return oldItem.second == newItem.second && return oldItem == newItem
oldItem.first.deepEquals(newItem.first)
} }
} }

View File

@ -38,6 +38,7 @@ import androidx.preference.PreferenceManager;
import com.google.android.material.snackbar.Snackbar; import com.google.android.material.snackbar.Snackbar;
import com.keylesspalace.tusky.adapter.AccountSelectionAdapter; import com.keylesspalace.tusky.adapter.AccountSelectionAdapter;
import com.keylesspalace.tusky.components.login.LoginActivity;
import com.keylesspalace.tusky.db.AccountEntity; import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.di.Injectable;

View File

@ -1,358 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.util.Log
import android.view.View
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import com.bumptech.glide.Glide
import com.keylesspalace.tusky.databinding.ActivityLoginBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.AccessToken
import com.keylesspalace.tusky.entity.AppCredentials
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.getNonNullString
import com.keylesspalace.tusky.util.viewBinding
import okhttp3.HttpUrl
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import javax.inject.Inject
class LoginActivity : BaseActivity(), Injectable {
@Inject
lateinit var mastodonApi: MastodonApi
private val binding by viewBinding(ActivityLoginBinding::inflate)
private lateinit var preferences: SharedPreferences
private val oauthRedirectUri: String
get() {
val scheme = getString(R.string.oauth_scheme)
val host = BuildConfig.APPLICATION_ID
return "$scheme://$host/"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
if (savedInstanceState == null && BuildConfig.CUSTOM_INSTANCE.isNotBlank() && !isAdditionalLogin()) {
binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE)
binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length)
}
if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) {
Glide.with(binding.loginLogo)
.load(BuildConfig.CUSTOM_LOGO_URL)
.placeholder(null)
.into(binding.loginLogo)
}
preferences = getSharedPreferences(
getString(R.string.preferences_file_key), Context.MODE_PRIVATE
)
binding.loginButton.setOnClickListener { onButtonClick() }
binding.whatsAnInstanceTextView.setOnClickListener {
val dialog = AlertDialog.Builder(this)
.setMessage(R.string.dialog_whats_an_instance)
.setPositiveButton(R.string.action_close, null)
.show()
val textView = dialog.findViewById<TextView>(android.R.id.message)
textView?.movementMethod = LinkMovementMethod.getInstance()
}
if (isAdditionalLogin()) {
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowTitleEnabled(false)
} else {
binding.toolbar.visibility = View.GONE
}
}
override fun requiresLogin(): Boolean {
return false
}
override fun finish() {
super.finish()
if (isAdditionalLogin()) {
overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right)
}
}
/**
* Obtain the oauth client credentials for this app. This is only necessary the first time the
* app is run on a given server instance. So, after the first authentication, they are
* saved in SharedPreferences and every subsequent run they are simply fetched from there.
*/
private fun onButtonClick() {
binding.loginButton.isEnabled = false
val domain = canonicalizeDomain(binding.domainEditText.text.toString())
try {
HttpUrl.Builder().host(domain).scheme("https").build()
} catch (e: IllegalArgumentException) {
setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_invalid_domain)
return
}
val callback = object : Callback<AppCredentials> {
override fun onResponse(
call: Call<AppCredentials>,
response: Response<AppCredentials>
) {
if (!response.isSuccessful) {
binding.loginButton.isEnabled = true
binding.domainTextInputLayout.error = getString(R.string.error_failed_app_registration)
setLoading(false)
Log.e(TAG, "App authentication failed. " + response.message())
return
}
val credentials = response.body()
val clientId = credentials!!.clientId
val clientSecret = credentials.clientSecret
preferences.edit()
.putString("domain", domain)
.putString("clientId", clientId)
.putString("clientSecret", clientSecret)
.apply()
redirectUserToAuthorizeAndLogin(domain, clientId)
}
override fun onFailure(call: Call<AppCredentials>, t: Throwable) {
binding.loginButton.isEnabled = true
binding.domainTextInputLayout.error = getString(R.string.error_failed_app_registration)
setLoading(false)
Log.e(TAG, Log.getStackTraceString(t))
}
}
mastodonApi
.authenticateApp(
domain, getString(R.string.app_name), oauthRedirectUri,
OAUTH_SCOPES, getString(R.string.tusky_website)
)
.enqueue(callback)
setLoading(true)
}
private fun redirectUserToAuthorizeAndLogin(domain: String, clientId: String) {
/* To authorize this app and log in it's necessary to redirect to the domain given,
* login there, and the server will redirect back to the app with its response. */
val endpoint = MastodonApi.ENDPOINT_AUTHORIZE
val parameters = mapOf(
"client_id" to clientId,
"redirect_uri" to oauthRedirectUri,
"response_type" to "code",
"scope" to OAUTH_SCOPES
)
val url = "https://" + domain + endpoint + "?" + toQueryString(parameters)
val uri = Uri.parse(url)
if (!openInCustomTab(uri, this)) {
val viewIntent = Intent(Intent.ACTION_VIEW, uri)
if (viewIntent.resolveActivity(packageManager) != null) {
startActivity(viewIntent)
} else {
binding.domainEditText.error = getString(R.string.error_no_web_browser_found)
setLoading(false)
}
}
}
override fun onStart() {
super.onStart()
/* Check if we are resuming during authorization by seeing if the intent contains the
* redirect that was given to the server. If so, its response is here! */
val uri = intent.data
val redirectUri = oauthRedirectUri
if (uri != null && uri.toString().startsWith(redirectUri)) {
// This should either have returned an authorization code or an error.
val code = uri.getQueryParameter("code")
val error = uri.getQueryParameter("error")
/* restore variables from SharedPreferences */
val domain = preferences.getNonNullString(DOMAIN, "")
val clientId = preferences.getNonNullString(CLIENT_ID, "")
val clientSecret = preferences.getNonNullString(CLIENT_SECRET, "")
if (code != null && domain.isNotEmpty() && clientId.isNotEmpty() && clientSecret.isNotEmpty()) {
setLoading(true)
/* Since authorization has succeeded, the final step to log in is to exchange
* the authorization code for an access token. */
val callback = object : Callback<AccessToken> {
override fun onResponse(call: Call<AccessToken>, response: Response<AccessToken>) {
if (response.isSuccessful) {
onLoginSuccess(response.body()!!.accessToken, domain)
} else {
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), response.message()))
}
}
override fun onFailure(call: Call<AccessToken>, t: Throwable) {
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), t.message))
}
}
mastodonApi.fetchOAuthToken(
domain, clientId, clientSecret, redirectUri, code,
"authorization_code"
).enqueue(callback)
} else if (error != null) {
/* Authorization failed. Put the error response where the user can read it and they
* can try again. */
setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_authorization_denied)
Log.e(TAG, "%s %s".format(getString(R.string.error_authorization_denied), error))
} else {
// This case means a junk response was received somehow.
setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_authorization_unknown)
}
} else {
// first show or user cancelled login
setLoading(false)
}
}
private fun setLoading(loadingState: Boolean) {
if (loadingState) {
binding.loginLoadingLayout.visibility = View.VISIBLE
binding.loginInputLayout.visibility = View.GONE
} else {
binding.loginLoadingLayout.visibility = View.GONE
binding.loginInputLayout.visibility = View.VISIBLE
binding.loginButton.isEnabled = true
}
}
private fun isAdditionalLogin(): Boolean {
return intent.getBooleanExtra(LOGIN_MODE, false)
}
private fun onLoginSuccess(accessToken: String, domain: String) {
setLoading(true)
accountManager.addAccount(accessToken, domain)
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
finish()
overridePendingTransition(R.anim.explode, R.anim.explode)
}
companion object {
private const val TAG = "LoginActivity" // logging tag
private const val OAUTH_SCOPES = "read write follow"
private const val LOGIN_MODE = "LOGIN_MODE"
private const val DOMAIN = "domain"
private const val CLIENT_ID = "clientId"
private const val CLIENT_SECRET = "clientSecret"
@JvmStatic
fun getIntent(context: Context, mode: Boolean): Intent {
val loginIntent = Intent(context, LoginActivity::class.java)
loginIntent.putExtra(LOGIN_MODE, mode)
return loginIntent
}
/** Make sure the user-entered text is just a fully-qualified domain name. */
private fun canonicalizeDomain(domain: String): String {
// Strip any schemes out.
var s = domain.replaceFirst("http://", "")
s = s.replaceFirst("https://", "")
// If a username was included (e.g. username@example.com), just take what's after the '@'.
val at = s.lastIndexOf('@')
if (at != -1) {
s = s.substring(at + 1)
}
return s.trim { it <= ' ' }
}
/**
* Chain together the key-value pairs into a query string, for either appending to a URL or
* as the content of an HTTP request.
*/
private fun toQueryString(parameters: Map<String, String>): String {
val s = StringBuilder()
var between = ""
for ((key, value) in parameters) {
s.append(between)
s.append(Uri.encode(key))
s.append("=")
s.append(Uri.encode(value))
between = "&"
}
return s.toString()
}
private fun openInCustomTab(uri: Uri, context: Context): Boolean {
val toolbarColor = ThemeUtils.getColor(context, R.attr.colorSurface)
val navigationbarColor = ThemeUtils.getColor(context, android.R.attr.navigationBarColor)
val navigationbarDividerColor = ThemeUtils.getColor(context, R.attr.dividerColor)
val colorSchemeParams = CustomTabColorSchemeParams.Builder()
.setToolbarColor(toolbarColor)
.setNavigationBarColor(navigationbarColor)
.setNavigationBarDividerColor(navigationbarDividerColor)
.build()
val customTabsIntent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(colorSchemeParams)
.build()
try {
customTabsIntent.launchUrl(context, uri)
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "Activity was not found for intent $customTabsIntent")
return false
}
return true
}
}
}

View File

@ -44,6 +44,7 @@ import androidx.appcompat.widget.PopupMenu
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.emoji.text.EmojiCompat import androidx.emoji.text.EmojiCompat
import androidx.emoji.text.EmojiCompat.InitCallback import androidx.emoji.text.EmojiCompat.InitCallback
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@ -74,9 +75,10 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canH
import com.keylesspalace.tusky.components.conversation.ConversationsRepository import com.keylesspalace.tusky.components.conversation.ConversationsRepository
import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.drafts.DraftsActivity 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.NotificationHelper
import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
import com.keylesspalace.tusky.components.search.SearchActivity import com.keylesspalace.tusky.components.search.SearchActivity
import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.databinding.ActivityMainBinding import com.keylesspalace.tusky.databinding.ActivityMainBinding
@ -132,6 +134,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.accelf.yuito.CustomUncaughtExceptionHandler
import net.accelf.yuito.FooterDrawerItem import net.accelf.yuito.FooterDrawerItem
import net.accelf.yuito.QuickTootViewModel import net.accelf.yuito.QuickTootViewModel
import net.accelf.yuito.streaming.StreamingManager import net.accelf.yuito.streaming.StreamingManager
@ -185,8 +188,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Thread.setDefaultUncaughtExceptionHandler(CustomUncaughtExceptionHandler(applicationContext))
installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// delete old notification channels
NotificationHelper.deleteLegacyNotificationChannels(this, accountManager)
val activeAccount = accountManager.activeAccount val activeAccount = accountManager.activeAccount
?: return // will be redirected to LoginActivity by BaseActivity ?: return // will be redirected to LoginActivity by BaseActivity
@ -515,10 +524,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
}, },
primaryDrawerItem { primaryDrawerItem {
nameRes = R.string.action_access_scheduled_toot nameRes = R.string.action_access_scheduled_posts
iconRes = R.drawable.ic_access_time iconRes = R.drawable.ic_access_time
onClick = { onClick = {
startActivityWithSlideInAnimation(ScheduledTootActivity.newIntent(context)) startActivityWithSlideInAnimation(ScheduledStatusActivity.newIntent(context))
} }
}, },
primaryDrawerItem { primaryDrawerItem {

View File

@ -1,52 +0,0 @@
/* Copyright 2018 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
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import net.accelf.yuito.CustomUncaughtExceptionHandler
import javax.inject.Inject
class SplashActivity : AppCompatActivity(), Injectable {
@Inject
lateinit var accountManager: AccountManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val customUncaughtExceptionHandler = CustomUncaughtExceptionHandler(applicationContext)
Thread.setDefaultUncaughtExceptionHandler(customUncaughtExceptionHandler)
/** delete old notification channels */
NotificationHelper.deleteLegacyNotificationChannels(this, accountManager)
/** Determine whether the user is currently logged in, and if so go ahead and load the
* timeline. Otherwise, start the activity_login screen. */
val intent = if (accountManager.activeAccount != null) {
Intent(this, MainActivity::class.java)
} else {
LoginActivity.getIntent(this, false)
}
startActivity(intent)
finish()
}
}

View File

@ -36,6 +36,7 @@ import android.view.MenuItem
import android.view.View import android.view.View
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import android.widget.Toast import android.widget.Toast
import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@ -205,10 +206,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val request = DownloadManager.Request(Uri.parse(url)) val request = DownloadManager.Request(Uri.parse(url))
request.setDestinationInExternalPublicDir( request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename)
Environment.DIRECTORY_PICTURES,
getString(R.string.app_name) + "/" + filename
)
downloadManager.enqueue(request) downloadManager.enqueue(request)
} }
@ -255,11 +253,11 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
} }
private fun shareFile(file: File, mimeType: String?) { private fun shareFile(file: File, mimeType: String?) {
val sendIntent = Intent() ShareCompat.IntentBuilder(this)
sendIntent.action = Intent.ACTION_SEND .setType(mimeType)
sendIntent.putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(applicationContext, "$APPLICATION_ID.fileprovider", file)) .addStream(FileProvider.getUriForFile(applicationContext, "$APPLICATION_ID.fileprovider", file))
sendIntent.type = mimeType .setChooserTitle(R.string.send_media_to)
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_media_to))) .startChooser()
} }
private var isCreating: Boolean = false private var isCreating: Boolean = false

View File

@ -18,7 +18,7 @@ import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.removeDuplicates import com.keylesspalace.tusky.util.removeDuplicates
@ -28,7 +28,7 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
protected val animateAvatar: Boolean, protected val animateAvatar: Boolean,
protected val animateEmojis: Boolean protected val animateEmojis: Boolean
) : RecyclerView.Adapter<RecyclerView.ViewHolder?>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder?>() {
var accountList = mutableListOf<Account>() var accountList = mutableListOf<TimelineAccount>()
private var bottomLoading: Boolean = false private var bottomLoading: Boolean = false
override fun getItemCount(): Int { override fun getItemCount(): Int {
@ -73,12 +73,12 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
} }
} }
fun update(newAccounts: List<Account>) { fun update(newAccounts: List<TimelineAccount>) {
accountList = removeDuplicates(newAccounts) accountList = removeDuplicates(newAccounts)
notifyDataSetChanged() notifyDataSetChanged()
} }
fun addItems(newAccounts: List<Account>) { fun addItems(newAccounts: List<TimelineAccount>) {
val end = accountList.size val end = accountList.size
val last = accountList[end - 1] val last = accountList[end - 1]
if (newAccounts.none { it.id == last.id }) { if (newAccounts.none { it.id == last.id }) {
@ -100,7 +100,7 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
} }
} }
fun removeItem(position: Int): Account? { fun removeItem(position: Int): TimelineAccount? {
if (position < 0 || position >= accountList.size) { if (position < 0 || position >= accountList.size) {
return null return null
} }
@ -109,7 +109,7 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
return account return account
} }
fun addItem(account: Account, position: Int) { fun addItem(account: TimelineAccount, position: Int) {
if (position < 0 || position > accountList.size) { if (position < 0 || position > accountList.size) {
return return
} }

View File

@ -9,7 +9,7 @@ import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.interfaces.AccountActionListener; import com.keylesspalace.tusky.interfaces.AccountActionListener;
import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.CustomEmojiHelper;
@ -33,9 +33,9 @@ public class AccountViewHolder extends RecyclerView.ViewHolder {
showBotOverlay = sharedPrefs.getBoolean("showBotOverlay", true); showBotOverlay = sharedPrefs.getBoolean("showBotOverlay", true);
} }
public void setupWithAccount(Account account, boolean animateAvatar, boolean animateEmojis) { public void setupWithAccount(TimelineAccount account, boolean animateAvatar, boolean animateEmojis) {
accountId = account.getId(); accountId = account.getId();
String format = username.getContext().getString(R.string.status_username_format); String format = username.getContext().getString(R.string.post_username_format);
String formattedUsername = String.format(format, account.getUsername()); String formattedUsername = String.format(format, account.getUsername());
username.setText(formattedUsername); username.setText(formattedUsername);
CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName, animateEmojis); CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName, animateEmojis);

View File

@ -22,7 +22,7 @@ import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.loadAvatar
@ -55,11 +55,11 @@ class BlocksAdapter(
private val unblock: ImageButton = itemView.findViewById(R.id.blocked_user_unblock) private val unblock: ImageButton = itemView.findViewById(R.id.blocked_user_unblock)
private var id: String? = null private var id: String? = null
fun setupWithAccount(account: Account, animateAvatar: Boolean, animateEmojis: Boolean) { fun setupWithAccount(account: TimelineAccount, animateAvatar: Boolean, animateEmojis: Boolean) {
id = account.id id = account.id
val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis) val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis)
displayName.text = emojifiedName displayName.text = emojifiedName
val format = username.context.getString(R.string.status_username_format) val format = username.context.getString(R.string.post_username_format)
val formattedUsername = String.format(format, account.username) val formattedUsername = String.format(format, account.username)
username.text = formattedUsername username.text = formattedUsername
val avatarRadius = avatar.context.resources val avatarRadius = avatar.context.resources

View File

@ -22,7 +22,7 @@ import android.text.style.StyleSpan
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.loadAvatar
@ -34,7 +34,7 @@ class FollowRequestViewHolder(
private val showHeader: Boolean private val showHeader: Boolean
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
fun setupWithAccount(account: Account, animateAvatar: Boolean, animateEmojis: Boolean) { fun setupWithAccount(account: TimelineAccount, animateAvatar: Boolean, animateEmojis: Boolean) {
val wrappedName = account.name.unicodeWrap() val wrappedName = account.name.unicodeWrap()
val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis) val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis)
binding.displayNameTextView.text = emojifiedName binding.displayNameTextView.text = emojifiedName
@ -45,7 +45,7 @@ class FollowRequestViewHolder(
}.emojify(account.emojis, itemView, animateEmojis) }.emojify(account.emojis, itemView, animateEmojis)
} }
binding.notificationTextView.visible(showHeader) binding.notificationTextView.visible(showHeader)
val format = itemView.context.getString(R.string.status_username_format) val format = itemView.context.getString(R.string.post_username_format)
val formattedUsername = String.format(format, account.username) val formattedUsername = String.format(format, account.username)
binding.usernameTextView.text = formattedUsername binding.usernameTextView.text = formattedUsername
val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)

View File

@ -9,7 +9,7 @@ import android.widget.TextView
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.loadAvatar
@ -69,7 +69,7 @@ class MutesAdapter(
private var notifications = false private var notifications = false
fun setupWithAccount( fun setupWithAccount(
account: Account, account: TimelineAccount,
mutingNotifications: Boolean?, mutingNotifications: Boolean?,
animateAvatar: Boolean, animateAvatar: Boolean,
animateEmojis: Boolean animateEmojis: Boolean
@ -77,7 +77,7 @@ class MutesAdapter(
id = account.id id = account.id
val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis) val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis)
displayName.text = emojifiedName displayName.text = emojifiedName
val format = username.context.getString(R.string.status_username_format) val format = username.context.getString(R.string.post_username_format)
val formattedUsername = String.format(format, account.username) val formattedUsername = String.format(format, account.username)
username.text = formattedUsername username.text = formattedUsername
val avatarRadius = avatar.context.resources val avatarRadius = avatar.context.resources

View File

@ -41,10 +41,10 @@ import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding; import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.interfaces.AccountActionListener; import com.keylesspalace.tusky.interfaces.AccountActionListener;
import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
@ -339,7 +339,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
this.statusDisplayOptions = statusDisplayOptions; this.statusDisplayOptions = statusDisplayOptions;
} }
void setMessage(Account account) { void setMessage(TimelineAccount account) {
Context context = message.getContext(); Context context = message.getContext();
String format = context.getString(R.string.notification_follow_format); String format = context.getString(R.string.notification_follow_format);
@ -350,7 +350,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
); );
message.setText(emojifiedMessage); message.setText(emojifiedMessage);
String username = context.getString(R.string.status_username_format, account.getUsername()); String username = context.getString(R.string.post_username_format, account.getUsername());
usernameView.setText(username); usernameView.setText(username);
CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify( CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify(
@ -447,7 +447,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
private void setUsername(String name) { private void setUsername(String name) {
Context context = username.getContext(); Context context = username.getContext();
String format = context.getString(R.string.status_username_format); String format = context.getString(R.string.post_username_format);
String usernameText = String.format(format, name); String usernameText = String.format(format, name);
username.setText(usernameText); username.setText(usernameText);
} }
@ -545,9 +545,9 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
if (statusViewData.isExpanded()) { if (statusViewData.isExpanded()) {
contentWarningButton.setText(R.string.status_content_warning_show_less); contentWarningButton.setText(R.string.post_content_warning_show_less);
} else { } else {
contentWarningButton.setText(R.string.status_content_warning_show_more); contentWarningButton.setText(R.string.post_content_warning_show_more);
} }
contentWarningButton.setOnClickListener(view -> { contentWarningButton.setOnClickListener(view -> {
@ -649,10 +649,10 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
contentCollapseButton.setVisibility(View.VISIBLE); contentCollapseButton.setVisibility(View.VISIBLE);
if (statusViewData.isCollapsed()) { if (statusViewData.isCollapsed()) {
contentCollapseButton.setText(R.string.status_content_warning_show_more); contentCollapseButton.setText(R.string.post_content_warning_show_more);
statusContent.setFilters(COLLAPSE_INPUT_FILTER); statusContent.setFilters(COLLAPSE_INPUT_FILTER);
} else { } else {
contentCollapseButton.setText(R.string.status_content_warning_show_less); contentCollapseButton.setText(R.string.post_content_warning_show_less);
statusContent.setFilters(NO_INPUT_FILTER); statusContent.setFilters(NO_INPUT_FILTER);
} }
} else { } else {

View File

@ -200,7 +200,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
protected void setUsername(String name) { protected void setUsername(String name) {
Context context = username.getContext(); Context context = username.getContext();
String usernameText = context.getString(R.string.status_username_format, name); String usernameText = context.getString(R.string.post_username_format, name);
username.setText(usernameText); username.setText(usernameText);
} }
@ -245,9 +245,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private void setContentWarningButtonText(boolean expanded) { private void setContentWarningButtonText(boolean expanded) {
if (expanded) { if (expanded) {
contentWarningButton.setText(R.string.status_content_warning_show_less); contentWarningButton.setText(R.string.post_content_warning_show_less);
} else { } else {
contentWarningButton.setText(R.string.status_content_warning_show_more); contentWarningButton.setText(R.string.post_content_warning_show_more);
} }
} }
@ -588,9 +588,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
} }
if (sensitive) { if (sensitive) {
sensitiveMediaWarning.setText(R.string.status_sensitive_media_title); sensitiveMediaWarning.setText(R.string.post_sensitive_media_title);
} else { } else {
sensitiveMediaWarning.setText(R.string.status_media_hidden_title); sensitiveMediaWarning.setText(R.string.post_media_hidden_title);
} }
sensitiveMediaWarning.setVisibility(showingContent ? View.GONE : View.VISIBLE); sensitiveMediaWarning.setVisibility(showingContent ? View.GONE : View.VISIBLE);
@ -635,7 +635,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private void updateMediaLabel(int index, boolean sensitive, boolean showingContent) { private void updateMediaLabel(int index, boolean sensitive, boolean showingContent) {
Context context = itemView.getContext(); Context context = itemView.getContext();
CharSequence label = (sensitive && !showingContent) ? CharSequence label = (sensitive && !showingContent) ?
context.getString(R.string.status_sensitive_media_title) : context.getString(R.string.post_sensitive_media_title) :
mediaDescriptions[index]; mediaDescriptions[index];
mediaLabels[index].setText(label); mediaLabels[index].setText(label);
} }
@ -687,7 +687,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
duration = formatDuration(attachment.getMeta().getDuration()) + " "; duration = formatDuration(attachment.getMeta().getDuration()) + " ";
} }
if (TextUtils.isEmpty(attachment.getDescription())) { if (TextUtils.isEmpty(attachment.getDescription())) {
return duration + context.getString(R.string.description_status_media_no_description_placeholder); return duration + context.getString(R.string.description_post_media_no_description_placeholder);
} else { } else {
return duration + attachment.getDescription(); return duration + attachment.getDescription();
} }
@ -935,9 +935,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions), getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions),
getReblogDescription(context, status), getReblogDescription(context, status),
status.getUsername(), status.getUsername(),
actionable.getReblogged() ? context.getString(R.string.description_status_reblogged) : "", actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "",
actionable.getFavourited() ? context.getString(R.string.description_status_favourited) : "", actionable.getFavourited() ? context.getString(R.string.description_post_favourited) : "",
actionable.getBookmarked() ? context.getString(R.string.description_status_bookmarked) : "", actionable.getBookmarked() ? context.getString(R.string.description_post_bookmarked) : "",
getMediaDescription(context, status), getMediaDescription(context, status),
getVisibilityDescription(context, actionable.getVisibility()), getVisibilityDescription(context, actionable.getVisibility()),
getFavsText(context, actionable.getFavouritesCount()), getFavsText(context, actionable.getFavouritesCount()),
@ -952,7 +952,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
Status reblog = status.getRebloggingStatus(); Status reblog = status.getRebloggingStatus();
if (reblog != null) { if (reblog != null) {
return context return context
.getString(R.string.status_boosted_format, reblog.getAccount().getUsername()); .getString(R.string.post_boosted_format, reblog.getAccount().getUsername());
} else { } else {
return ""; return "";
} }
@ -969,20 +969,20 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
(builder, a) -> { (builder, a) -> {
if (a.getDescription() == null) { if (a.getDescription() == null) {
String placeholder = String placeholder =
context.getString(R.string.description_status_media_no_description_placeholder); context.getString(R.string.description_post_media_no_description_placeholder);
return builder.append(placeholder); return builder.append(placeholder);
} else { } else {
builder.append("; "); builder.append("; ");
return builder.append(a.getDescription()); return builder.append(a.getDescription());
} }
}); });
return context.getString(R.string.description_status_media, mediaDescriptions); return context.getString(R.string.description_post_media, mediaDescriptions);
} }
private static CharSequence getContentWarningDescription(Context context, private static CharSequence getContentWarningDescription(Context context,
@NonNull StatusViewData.Concrete status) { @NonNull StatusViewData.Concrete status) {
if (!TextUtils.isEmpty(status.getSpoilerText())) { if (!TextUtils.isEmpty(status.getSpoilerText())) {
return context.getString(R.string.description_status_cw, status.getSpoilerText()); return context.getString(R.string.description_post_cw, status.getSpoilerText());
} else { } else {
return ""; return "";
} }

View File

@ -86,7 +86,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
final StatusDisplayOptions statusDisplayOptions) { final StatusDisplayOptions statusDisplayOptions) {
Context context = statusInfo.getContext(); Context context = statusInfo.getContext();
CharSequence wrappedName = StringUtils.unicodeWrap(name); CharSequence wrappedName = StringUtils.unicodeWrap(name);
CharSequence boostedText = context.getString(R.string.status_boosted_format, wrappedName); CharSequence boostedText = context.getString(R.string.post_boosted_format, wrappedName);
CharSequence emojifiedText = CustomEmojiHelper.emojify( CharSequence emojifiedText = CustomEmojiHelper.emojify(
boostedText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis() boostedText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis()
); );
@ -118,10 +118,10 @@ public class StatusViewHolder extends StatusBaseViewHolder {
contentCollapseButton.setVisibility(View.VISIBLE); contentCollapseButton.setVisibility(View.VISIBLE);
if (status.isCollapsed()) { if (status.isCollapsed()) {
contentCollapseButton.setText(R.string.status_content_warning_show_more); contentCollapseButton.setText(R.string.post_content_warning_show_more);
content.setFilters(COLLAPSE_INPUT_FILTER); content.setFilters(COLLAPSE_INPUT_FILTER);
} else { } else {
contentCollapseButton.setText(R.string.status_content_warning_show_less); contentCollapseButton.setText(R.string.post_content_warning_show_less);
content.setFilters(NO_INPUT_FILTER); content.setFilters(NO_INPUT_FILTER);
} }
} else { } else {

View File

@ -239,7 +239,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountFragmentViewPager.adapter = adapter binding.accountFragmentViewPager.adapter = adapter
binding.accountFragmentViewPager.offscreenPageLimit = 2 binding.accountFragmentViewPager.offscreenPageLimit = 2
val pageTitles = arrayOf(getString(R.string.title_statuses), getString(R.string.title_statuses_with_replies), getString(R.string.title_statuses_pinned), getString(R.string.title_media)) val pageTitles = arrayOf(getString(R.string.title_posts), getString(R.string.title_posts_with_replies), getString(R.string.title_posts_pinned), getString(R.string.title_media))
TabLayoutMediator(binding.accountTabLayout, binding.accountFragmentViewPager) { tab, position -> TabLayoutMediator(binding.accountTabLayout, binding.accountFragmentViewPager) { tab, position ->
tab.text = pageTitles[position] tab.text = pageTitles[position]
@ -411,7 +411,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
private fun onAccountChanged(account: Account?) { private fun onAccountChanged(account: Account?) {
loadedAccount = account ?: return loadedAccount = account ?: return
val usernameFormatted = getString(R.string.status_username_format, account.username) val usernameFormatted = getString(R.string.post_username_format, account.username)
binding.accountUsernameTextView.text = usernameFormatted binding.accountUsernameTextView.text = usernameFormatted
binding.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis) binding.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis)
@ -482,7 +482,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
} catch (e: IllegalStateException) { } catch (e: IllegalStateException) {
supportActionBar?.title = emojifiedName supportActionBar?.title = emojifiedName
} }
supportActionBar?.subtitle = String.format(getString(R.string.status_username_format), account.username) supportActionBar?.subtitle = String.format(getString(R.string.post_username_format), account.username)
} }
} }
@ -499,7 +499,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
} }
binding.accountMovedDisplayName.text = movedAccount.name binding.accountMovedDisplayName.text = movedAccount.name
binding.accountMovedUsername.text = getString(R.string.status_username_format, movedAccount.username) binding.accountMovedUsername.text = getString(R.string.post_username_format, movedAccount.username)
val avatarRadius = resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) val avatarRadius = resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)

View File

@ -16,7 +16,9 @@
package com.keylesspalace.tusky.components.compose package com.keylesspalace.tusky.components.compose
import android.Manifest import android.Manifest
import android.app.NotificationManager
import android.app.ProgressDialog import android.app.ProgressDialog
import android.content.ClipData
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
@ -46,8 +48,8 @@ import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.view.inputmethod.InputConnectionCompat import androidx.core.view.ContentInfoCompat
import androidx.core.view.inputmethod.InputContentInfoCompat import androidx.core.view.OnReceiveContentListener
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged import androidx.core.widget.doAfterTextChanged
@ -109,7 +111,7 @@ class ComposeActivity :
ComposeAutoCompleteAdapter.AutocompletionProvider, ComposeAutoCompleteAdapter.AutocompletionProvider,
OnEmojiSelectedListener, OnEmojiSelectedListener,
Injectable, Injectable,
InputConnectionCompat.OnCommitContentListener, OnReceiveContentListener,
ComposeScheduleView.OnTimeSetListener { ComposeScheduleView.OnTimeSetListener {
@Inject @Inject
@ -155,6 +157,18 @@ class ComposeActivity :
public override fun onCreate(savedInstanceState: Bundle?) { public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val notificationId = intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)
if (notificationId != -1) {
// ComposeActivity was opened from a notification, delete the notification
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(notificationId)
}
val accountId = intent.getLongExtra(ACCOUNT_ID_EXTRA, -1)
if (accountId != -1L) {
accountManager.setActiveAccount(accountId)
}
val preferences = PreferenceManager.getDefaultSharedPreferences(this) val preferences = PreferenceManager.getDefaultSharedPreferences(this)
val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT) val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT)
if (theme == "black") { if (theme == "black") {
@ -194,9 +208,9 @@ class ComposeActivity :
viewModel.setup(composeOptions) viewModel.setup(composeOptions)
setupReplyViews(composeOptions?.replyingStatusAuthor, composeOptions?.replyingStatusContent) setupReplyViews(composeOptions?.replyingStatusAuthor, composeOptions?.replyingStatusContent)
setupQuoteView(composeOptions?.quoteStatusAuthor, composeOptions?.quoteStatusContent) setupQuoteView(composeOptions?.quoteStatusAuthor, composeOptions?.quoteStatusContent)
val tootText = composeOptions?.tootText val statusContent = composeOptions?.content
if (!tootText.isNullOrEmpty()) { if (!statusContent.isNullOrEmpty()) {
binding.composeEditField.setText(tootText) binding.composeEditField.setText(statusContent)
} }
viewModel.loadInstanceDataFromNetwork(loadInstanceData(preferences, composeOptions?.tootRightNow == true)) viewModel.loadInstanceDataFromNetwork(loadInstanceData(preferences, composeOptions?.tootRightNow == true))
@ -249,26 +263,25 @@ class ComposeActivity :
} }
} }
} }
} else if (type == "text/plain" && intent.action == Intent.ACTION_SEND) { }
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT) val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
val text = intent.getStringExtra(Intent.EXTRA_TEXT).orEmpty() val text = intent.getStringExtra(Intent.EXTRA_TEXT).orEmpty()
val shareBody = if (!subject.isNullOrBlank() && subject !in text) { val shareBody = if (!subject.isNullOrBlank() && subject !in text) {
subject + '\n' + text subject + '\n' + text
} else { } else {
text text
} }
if (shareBody.isNotBlank()) { if (shareBody.isNotBlank()) {
val start = binding.composeEditField.selectionStart.coerceAtLeast(0) val start = binding.composeEditField.selectionStart.coerceAtLeast(0)
val end = binding.composeEditField.selectionEnd.coerceAtLeast(0) val end = binding.composeEditField.selectionEnd.coerceAtLeast(0)
val left = min(start, end) val left = min(start, end)
val right = max(start, end) val right = max(start, end)
binding.composeEditField.text.replace(left, right, shareBody, 0, shareBody.length) binding.composeEditField.text.replace(left, right, shareBody, 0, shareBody.length)
// move edittext cursor to first when shareBody parsed // move edittext cursor to first when shareBody parsed
binding.composeEditField.text.insert(0, "\n") binding.composeEditField.text.insert(0, "\n")
binding.composeEditField.setSelection(0) binding.composeEditField.setSelection(0)
}
} }
} }
} }
@ -336,7 +349,7 @@ class ComposeActivity :
} }
private fun setupComposeField(preferences: SharedPreferences, startingText: String?) { private fun setupComposeField(preferences: SharedPreferences, startingText: String?) {
binding.composeEditField.setOnCommitContentListener(this) binding.composeEditField.setOnReceiveContentListener(this)
binding.composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) } binding.composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) }
@ -776,7 +789,9 @@ class ComposeActivity :
val urlSpans = binding.composeEditField.urls val urlSpans = binding.composeEditField.urls
if (urlSpans != null) { if (urlSpans != null) {
for (span in urlSpans) { for (span in urlSpans) {
offset += max(0, span.url.length - charactersReservedPerUrl) // it's expected that this will be negative
// when the url length is less than the reserved character count
offset += (span.url.length - charactersReservedPerUrl)
} }
} }
var length = binding.composeEditField.length() - offset var length = binding.composeEditField.length() - offset
@ -819,26 +834,18 @@ class ComposeActivity :
} }
} }
/** This is for the fancy keyboards which can insert images and stuff. */ /** This is for the fancy keyboards which can insert images and stuff, and drag&drop etc */
override fun onCommitContent(inputContentInfo: InputContentInfoCompat, flags: Int, opts: Bundle?): Boolean { override fun onReceiveContent(view: View, contentInfo: ContentInfoCompat): ContentInfoCompat? {
// Verify the returned content's type is of the correct MIME type if (contentInfo.clip.description.hasMimeType("image/*")) {
val supported = inputContentInfo.description.hasMimeType("image/*") val split = contentInfo.partition { item: ClipData.Item -> item.uri != null }
split.first?.let { content ->
if (supported) { for (i in 0 until content.clip.itemCount) {
val lacksPermission = (flags and InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0 pickMedia(content.clip.getItemAt(i).uri)
if (lacksPermission) {
try {
inputContentInfo.requestPermission()
} catch (e: Exception) {
Log.e(TAG, "InputContentInfoCompat#requestPermission() failed." + e.message)
return false
} }
} }
pickMedia(inputContentInfo.contentUri, inputContentInfo) return split.second
return true
} }
return contentInfo
return false
} }
private fun sendStatus() { private fun sendStatus() {
@ -865,12 +872,11 @@ class ComposeActivity :
} }
viewModel.sendStatus(contentText, spoilerText).observe( viewModel.sendStatus(contentText, spoilerText).observe(
this, this
{ ) {
finishingUploadDialog?.dismiss() finishingUploadDialog?.dismiss()
deleteDraftAndFinish() deleteDraftAndFinish()
} }
)
} else { } else {
binding.composeEditField.error = getString(R.string.error_compose_character_limit) binding.composeEditField.error = getString(R.string.error_compose_character_limit)
enableButtons(true) enableButtons(true)
@ -940,12 +946,9 @@ class ComposeActivity :
viewModel.removeMediaFromQueue(item) viewModel.removeMediaFromQueue(item)
} }
private fun pickMedia(uri: Uri, contentInfoCompat: InputContentInfoCompat? = null) { private fun pickMedia(uri: Uri) {
withLifecycleContext { withLifecycleContext {
viewModel.pickMedia(uri).observe { exceptionOrItem -> viewModel.pickMedia(uri).observe { exceptionOrItem ->
contentInfoCompat?.releasePermission()
exceptionOrItem.asLeftOrNull()?.let { exceptionOrItem.asLeftOrNull()?.let {
val errorId = when (it) { val errorId = when (it) {
is VideoSizeException -> { is VideoSizeException -> {
@ -1098,29 +1101,29 @@ class ComposeActivity :
@Parcelize @Parcelize
data class ComposeOptions( data class ComposeOptions(
// Let's keep fields var until all consumers are Kotlin // Let's keep fields var until all consumers are Kotlin
var scheduledTootId: String? = null, var scheduledTootId: String? = null,
var draftId: Int? = null, var draftId: Int? = null,
var tootText: String? = null, var content: String? = null,
var mediaUrls: List<String>? = null, var mediaUrls: List<String>? = null,
var mediaDescriptions: List<String>? = null, var mediaDescriptions: List<String>? = null,
var mentionedUsernames: Set<String>? = null, var mentionedUsernames: Set<String>? = null,
var inReplyToId: String? = null, var inReplyToId: String? = null,
var quoteId: String? = null, var quoteId: String? = null,
var quoteStatusAuthor: String? = null, var quoteStatusAuthor: String? = null,
var quoteStatusContent: String? = null, var quoteStatusContent: String? = null,
var replyVisibility: Status.Visibility? = null, var replyVisibility: Status.Visibility? = null,
var visibility: Status.Visibility? = null, var visibility: Status.Visibility? = null,
var contentWarning: String? = null, var contentWarning: String? = null,
var replyingStatusAuthor: String? = null, var replyingStatusAuthor: String? = null,
var replyingStatusContent: String? = null, var replyingStatusContent: String? = null,
var mediaAttachments: List<Attachment>? = null, var mediaAttachments: List<Attachment>? = null,
var draftAttachments: List<DraftAttachment>? = null, var draftAttachments: List<DraftAttachment>? = null,
var scheduledAt: String? = null, var scheduledAt: String? = null,
var sensitive: Boolean? = null, var sensitive: Boolean? = null,
var poll: NewPoll? = null, var poll: NewPoll? = null,
var modifiedInitialState: Boolean? = null, var modifiedInitialState: Boolean? = null,
var tootRightNow: Boolean? = null var tootRightNow: Boolean? = null,
) : Parcelable ) : Parcelable
companion object { companion object {
@ -1128,6 +1131,8 @@ class ComposeActivity :
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1 private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS" internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS"
private const val NOTIFICATION_ID_EXTRA = "NOTIFICATION_ID"
private const val ACCOUNT_ID_EXTRA = "ACCOUNT_ID"
private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI" private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI"
@JvmField @JvmField
@ -1136,10 +1141,28 @@ class ComposeActivity :
const val PREF_DEFAULT_TAG = "default_tag" const val PREF_DEFAULT_TAG = "default_tag"
const val PREF_USE_DEFAULT_TAG = "use_default_tag" const val PREF_USE_DEFAULT_TAG = "use_default_tag"
/**
* @param options ComposeOptions to configure the ComposeActivity
* @param notificationId the id of the notification that starts the Activity
* @param accountId the id of the account to compose with, null for the current account
* @return an Intent to start the ComposeActivity
*/
@JvmStatic @JvmStatic
fun startIntent(context: Context, options: ComposeOptions): Intent { @JvmOverloads
fun startIntent(
context: Context,
options: ComposeOptions,
notificationId: Int? = null,
accountId: Long? = null
): Intent {
return Intent(context, ComposeActivity::class.java).apply { return Intent(context, ComposeActivity::class.java).apply {
putExtra(COMPOSE_OPTIONS_EXTRA, options) putExtra(COMPOSE_OPTIONS_EXTRA, options)
if (notificationId != null) {
putExtra(NOTIFICATION_ID_EXTRA, notificationId)
}
if (accountId != null) {
putExtra(ACCOUNT_ID_EXTRA, accountId)
}
} }
} }

View File

@ -16,7 +16,6 @@
package com.keylesspalace.tusky.components.compose; package com.keylesspalace.tusky.components.compose;
import android.content.Context; import android.content.Context;
import android.preference.PreferenceManager;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
@ -28,9 +27,9 @@ import android.widget.TextView;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.HashTag; import com.keylesspalace.tusky.entity.HashTag;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper;
@ -144,9 +143,9 @@ public class ComposeAutoCompleteAdapter extends BaseAdapter
AccountResult accountResult = ((AccountResult) getItem(position)); AccountResult accountResult = ((AccountResult) getItem(position));
if (accountResult != null) { if (accountResult != null) {
Account account = accountResult.account; TimelineAccount account = accountResult.account;
String formattedUsername = context.getString( String formattedUsername = context.getString(
R.string.status_username_format, R.string.post_username_format,
account.getUsername() account.getUsername()
); );
accountViewHolder.username.setText(formattedUsername); accountViewHolder.username.setText(formattedUsername);
@ -268,9 +267,9 @@ public class ComposeAutoCompleteAdapter extends BaseAdapter
} }
public final static class AccountResult extends AutocompleteResult { public final static class AccountResult extends AutocompleteResult {
private final Account account; private final TimelineAccount account;
public AccountResult(Account account) { public AccountResult(TimelineAccount account) {
this.account = account; this.account = account;
} }
} }

View File

@ -34,7 +34,7 @@ import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.TootToSend import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.VersionUtils import com.keylesspalace.tusky.util.VersionUtils
@ -315,25 +315,25 @@ class ComposeViewModel @Inject constructor(
mediaDescriptions.add(item.description ?: "") mediaDescriptions.add(item.description ?: "")
} }
val tootToSend = TootToSend( val tootToSend = StatusToSend(
text = content, text = content,
warningText = spoilerText, warningText = spoilerText,
visibility = statusVisibility.value!!.serverString(), visibility = statusVisibility.value!!.serverString(),
sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value!! || showContentWarning.value!!), sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value!! || showContentWarning.value!!),
mediaIds = mediaIds, mediaIds = mediaIds,
mediaUris = mediaUris.map { it.toString() }, mediaUris = mediaUris.map { it.toString() },
mediaDescriptions = mediaDescriptions, mediaDescriptions = mediaDescriptions,
scheduledAt = scheduledAt.value, scheduledAt = scheduledAt.value,
inReplyToId = inReplyToId, inReplyToId = inReplyToId,
poll = poll.value, poll = poll.value,
replyingStatusContent = null, replyingStatusContent = null,
replyingStatusAuthorUsername = null, replyingStatusAuthorUsername = null,
quoteId = quoteId, quoteId = quoteId,
accountId = accountManager.activeAccount!!.id, accountId = accountManager.activeAccount!!.id,
draftId = draftId, draftId = draftId,
idempotencyKey = randomAlphanumericString(16), idempotencyKey = randomAlphanumericString(16),
retries = 0 retries = 0
) )
serviceClient.sendToot(tootToSend) serviceClient.sendToot(tootToSend)
} }
@ -468,7 +468,7 @@ class ComposeViewModel @Inject constructor(
draftId = composeOptions?.draftId ?: 0 draftId = composeOptions?.draftId ?: 0
scheduledTootId = composeOptions?.scheduledTootId scheduledTootId = composeOptions?.scheduledTootId
startingText = composeOptions?.tootText startingText = composeOptions?.content
val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN
if (tootVisibility.num != Status.Visibility.UNKNOWN.num) { if (tootVisibility.num != Status.Visibility.UNKNOWN.num) {

View File

@ -15,6 +15,7 @@
package com.keylesspalace.tusky.components.compose package com.keylesspalace.tusky.components.compose
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
@ -37,6 +38,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import java.io.File import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.util.Date import java.util.Date
@ -83,36 +85,70 @@ class MediaUploader @Inject constructor(
fun prepareMedia(inUri: Uri): Single<PreparedMedia> { fun prepareMedia(inUri: Uri): Single<PreparedMedia> {
return Single.fromCallable { return Single.fromCallable {
var mediaSize = getMediaSize(contentResolver, inUri) var mediaSize = MEDIA_SIZE_UNKNOWN
var uri = inUri var uri = inUri
val mimeType = contentResolver.getType(uri) var mimeType: String? = null
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
try { try {
contentResolver.openInputStream(inUri).use { input -> when (inUri.scheme) {
if (input == null) { ContentResolver.SCHEME_CONTENT -> {
Log.w(TAG, "Media input is null")
uri = inUri mimeType = contentResolver.getType(uri)
return@use
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
contentResolver.openInputStream(inUri).use { input ->
if (input == null) {
Log.w(TAG, "Media input is null")
uri = inUri
return@use
}
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
FileOutputStream(file.absoluteFile).use { out ->
input.copyTo(out)
uri = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID + ".fileprovider",
file
)
mediaSize = getMediaSize(contentResolver, uri)
}
}
} }
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir) ContentResolver.SCHEME_FILE -> {
FileOutputStream(file.absoluteFile).use { out -> val path = uri.path
input.copyTo(out) if (path == null) {
uri = FileProvider.getUriForFile( Log.w(TAG, "empty uri path $uri")
context, throw CouldNotOpenFileException()
BuildConfig.APPLICATION_ID + ".fileprovider", }
file val inputFile = File(path)
) val suffix = inputFile.name.substringAfterLast('.', "tmp")
mediaSize = getMediaSize(contentResolver, uri) mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix)
val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir)
val input = FileInputStream(inputFile)
FileOutputStream(file.absoluteFile).use { out ->
input.copyTo(out)
uri = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID + ".fileprovider",
file
)
mediaSize = getMediaSize(contentResolver, uri)
}
}
else -> {
Log.w(TAG, "Unknown uri scheme $uri")
throw CouldNotOpenFileException()
} }
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.w(TAG, e) Log.w(TAG, e)
uri = inUri throw CouldNotOpenFileException()
} }
if (mediaSize == MEDIA_SIZE_UNKNOWN) { if (mediaSize == MEDIA_SIZE_UNKNOWN) {
throw CouldNotOpenFileException() Log.w(TAG, "Could not determine file size of upload")
throw MediaTypeException()
} }
if (mimeType != null) { if (mimeType != null) {
@ -138,6 +174,7 @@ class MediaUploader @Inject constructor(
} }
} }
} else { } else {
Log.w(TAG, "Could not determine mime type of upload")
throw MediaTypeException() throw MediaTypeException()
} }
} }

View File

@ -22,6 +22,8 @@ import android.util.AttributeSet
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection import android.view.inputmethod.InputConnection
import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView
import androidx.core.view.OnReceiveContentListener
import androidx.core.view.ViewCompat
import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.emoji.widget.EmojiEditTextHelper import androidx.emoji.widget.EmojiEditTextHelper
@ -32,41 +34,33 @@ class EditTextTyped @JvmOverloads constructor(
) : ) :
AppCompatMultiAutoCompleteTextView(context, attributeSet) { AppCompatMultiAutoCompleteTextView(context, attributeSet) {
private var onCommitContentListener: InputConnectionCompat.OnCommitContentListener? = null
private val emojiEditTextHelper: EmojiEditTextHelper = EmojiEditTextHelper(this) private val emojiEditTextHelper: EmojiEditTextHelper = EmojiEditTextHelper(this)
init { init {
// fix a bug with autocomplete and some keyboards // fix a bug with autocomplete and some keyboards
val newInputType = inputType and (inputType xor InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE) val newInputType = inputType and (inputType xor InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE)
inputType = newInputType inputType = newInputType
super.setKeyListener(getEmojiEditTextHelper().getKeyListener(keyListener)) super.setKeyListener(emojiEditTextHelper.getKeyListener(keyListener))
} }
override fun setKeyListener(input: KeyListener) { override fun setKeyListener(input: KeyListener?) {
super.setKeyListener(getEmojiEditTextHelper().getKeyListener(input)) if (input != null) {
super.setKeyListener(emojiEditTextHelper.getKeyListener(input))
} else {
super.setKeyListener(input)
}
} }
fun setOnCommitContentListener(listener: InputConnectionCompat.OnCommitContentListener) { fun setOnReceiveContentListener(listener: OnReceiveContentListener) {
onCommitContentListener = listener ViewCompat.setOnReceiveContentListener(this, arrayOf("image/*"), listener)
} }
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection { override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
val connection = super.onCreateInputConnection(editorInfo) val connection = super.onCreateInputConnection(editorInfo)
return if (onCommitContentListener != null) { EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*")) return emojiEditTextHelper.onCreateInputConnection(
getEmojiEditTextHelper().onCreateInputConnection( InputConnectionCompat.createWrapper(this, connection, editorInfo),
InputConnectionCompat.createWrapper( editorInfo
connection, editorInfo, )!!
onCommitContentListener!!
),
editorInfo
)!!
} else {
connection
}
}
private fun getEmojiEditTextHelper(): EmojiEditTextHelper {
return emojiEditTextHelper
} }
} }

View File

@ -16,18 +16,17 @@
package com.keylesspalace.tusky.components.conversation package com.keylesspalace.tusky.components.conversation
import android.text.Spanned import android.text.Spanned
import android.text.SpannedString
import androidx.room.Embedded import androidx.room.Embedded
import androidx.room.Entity import androidx.room.Entity
import androidx.room.TypeConverters import androidx.room.TypeConverters
import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Conversation import com.keylesspalace.tusky.entity.Conversation
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.shouldTrimStatus import com.keylesspalace.tusky.util.shouldTrimStatus
import java.util.Date import java.util.Date
@ -48,17 +47,15 @@ data class ConversationAccountEntity(
val avatar: String, val avatar: String,
val emojis: List<Emoji> val emojis: List<Emoji>
) { ) {
fun toAccount(): Account { fun toAccount(): TimelineAccount {
return Account( return TimelineAccount(
id = id, id = id,
username = username, username = username,
displayName = displayName, displayName = displayName,
url = "",
avatar = avatar, avatar = avatar,
emojis = emojis, emojis = emojis,
url = "",
localUsername = "", localUsername = "",
note = SpannedString(""),
header = ""
) )
} }
} }
@ -100,7 +97,7 @@ data class ConversationStatusEntity(
if (inReplyToId != other.inReplyToId) return false if (inReplyToId != other.inReplyToId) return false
if (inReplyToAccountId != other.inReplyToAccountId) return false if (inReplyToAccountId != other.inReplyToAccountId) return false
if (account != other.account) return false if (account != other.account) return false
if (content.toString() != other.content.toString()) return false // TODO find a better method to compare two spanned strings if (content.toString() != other.content.toString()) return false
if (createdAt != other.createdAt) return false if (createdAt != other.createdAt) return false
if (emojis != other.emojis) return false if (emojis != other.emojis) return false
if (favouritesCount != other.favouritesCount) return false if (favouritesCount != other.favouritesCount) return false
@ -126,7 +123,7 @@ data class ConversationStatusEntity(
result = 31 * result + (inReplyToId?.hashCode() ?: 0) result = 31 * result + (inReplyToId?.hashCode() ?: 0)
result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0) result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0)
result = 31 * result + account.hashCode() result = 31 * result + account.hashCode()
result = 31 * result + content.hashCode() result = 31 * result + content.toString().hashCode()
result = 31 * result + createdAt.hashCode() result = 31 * result + createdAt.hashCode()
result = 31 * result + emojis.hashCode() result = 31 * result + emojis.hashCode()
result = 31 * result + favouritesCount result = 31 * result + favouritesCount
@ -177,7 +174,7 @@ data class ConversationStatusEntity(
} }
} }
fun Account.toEntity() = fun TimelineAccount.toEntity() =
ConversationAccountEntity( ConversationAccountEntity(
id = id, id = id,
username = username, username = username,

View File

@ -154,10 +154,10 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
contentCollapseButton.setVisibility(View.VISIBLE); contentCollapseButton.setVisibility(View.VISIBLE);
if (collapsed) { if (collapsed) {
contentCollapseButton.setText(R.string.status_content_warning_show_more); contentCollapseButton.setText(R.string.post_content_warning_show_more);
content.setFilters(COLLAPSE_INPUT_FILTER); content.setFilters(COLLAPSE_INPUT_FILTER);
} else { } else {
contentCollapseButton.setText(R.string.status_content_warning_show_less); contentCollapseButton.setText(R.string.post_content_warning_show_less);
content.setFilters(NO_INPUT_FILTER); content.setFilters(NO_INPUT_FILTER);
} }
} else { } else {

View File

@ -90,14 +90,14 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
if (draft.inReplyToId != null) { if (draft.inReplyToId != null) {
bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED
viewModel.getToot(draft.inReplyToId) viewModel.getStatus(draft.inReplyToId)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this)) .autoDispose(from(this))
.subscribe( .subscribe(
{ status -> { status ->
val composeOptions = ComposeActivity.ComposeOptions( val composeOptions = ComposeActivity.ComposeOptions(
draftId = draft.id, draftId = draft.id,
tootText = draft.content, content = draft.content,
contentWarning = draft.contentWarning, contentWarning = draft.contentWarning,
inReplyToId = draft.inReplyToId, inReplyToId = draft.inReplyToId,
replyingStatusContent = status.content.toString(), replyingStatusContent = status.content.toString(),
@ -121,7 +121,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
if (throwable is HttpException && throwable.code() == 404) { if (throwable is HttpException && throwable.code() == 404) {
// the original status to which a reply was drafted has been deleted // the original status to which a reply was drafted has been deleted
// let's open the ComposeActivity without reply information // let's open the ComposeActivity without reply information
Toast.makeText(this, getString(R.string.drafts_toot_reply_removed), Toast.LENGTH_LONG).show() Toast.makeText(this, getString(R.string.drafts_post_reply_removed), Toast.LENGTH_LONG).show()
openDraftWithoutReply(draft) openDraftWithoutReply(draft)
} else { } else {
Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT) Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT)
@ -137,7 +137,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
private fun openDraftWithoutReply(draft: DraftEntity) { private fun openDraftWithoutReply(draft: DraftEntity) {
val composeOptions = ComposeActivity.ComposeOptions( val composeOptions = ComposeActivity.ComposeOptions(
draftId = draft.id, draftId = draft.id,
tootText = draft.content, content = draft.content,
contentWarning = draft.contentWarning, contentWarning = draft.contentWarning,
draftAttachments = draft.attachments, draftAttachments = draft.attachments,
poll = draft.poll, poll = draft.poll,

View File

@ -60,8 +60,8 @@ class DraftsViewModel @Inject constructor(
} }
} }
fun getToot(tootId: String): Single<Status> { fun getStatus(statusId: String): Single<Status> {
return api.status(tootId) return api.status(statusId)
} }
override fun onCleared() { override fun onCleared() {

View File

@ -0,0 +1,288 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.login
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.util.Log
import android.view.View
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig
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.AppCredentials
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.getNonNullString
import com.keylesspalace.tusky.util.viewBinding
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import javax.inject.Inject
/** Main login page, the first thing that users see. Has prompt for instance and login button. */
class LoginActivity : BaseActivity(), Injectable {
@Inject
lateinit var mastodonApi: MastodonApi
private val binding by viewBinding(ActivityLoginBinding::inflate)
private lateinit var preferences: SharedPreferences
private val oauthRedirectUri: String
get() {
val scheme = getString(R.string.oauth_scheme)
val host = BuildConfig.APPLICATION_ID
return "$scheme://$host/"
}
private val doWebViewAuth = registerForActivityResult(OauthLogin()) { result ->
when (result) {
is LoginResult.Ok -> lifecycleScope.launch {
fetchOauthToken(result.code)
}
is LoginResult.Err -> {
// Authorization failed. Put the error response where the user can read it and they
// can try again.
setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_authorization_denied)
Log.e(
TAG,
"%s %s".format(
getString(R.string.error_authorization_denied),
result.errorMessage
)
)
}
is LoginResult.Cancel -> {
setLoading(false)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
if (savedInstanceState == null &&
BuildConfig.CUSTOM_INSTANCE.isNotBlank() &&
!isAdditionalLogin()
) {
binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE)
binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length)
}
if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) {
Glide.with(binding.loginLogo)
.load(BuildConfig.CUSTOM_LOGO_URL)
.placeholder(null)
.into(binding.loginLogo)
}
preferences = getSharedPreferences(
getString(R.string.preferences_file_key), Context.MODE_PRIVATE
)
binding.loginButton.setOnClickListener { onButtonClick() }
binding.whatsAnInstanceTextView.setOnClickListener {
val dialog = AlertDialog.Builder(this)
.setMessage(R.string.dialog_whats_an_instance)
.setPositiveButton(R.string.action_close, null)
.show()
val textView = dialog.findViewById<TextView>(android.R.id.message)
textView?.movementMethod = LinkMovementMethod.getInstance()
}
if (isAdditionalLogin()) {
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowTitleEnabled(false)
} else {
binding.toolbar.visibility = View.GONE
}
}
override fun requiresLogin(): Boolean {
return false
}
override fun finish() {
super.finish()
if (isAdditionalLogin()) {
overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right)
}
}
/**
* Obtain the oauth client credentials for this app. This is only necessary the first time the
* app is run on a given server instance. So, after the first authentication, they are
* saved in SharedPreferences and every subsequent run they are simply fetched from there.
*/
private fun onButtonClick() {
binding.loginButton.isEnabled = false
binding.domainTextInputLayout.error = null
val domain = canonicalizeDomain(binding.domainEditText.text.toString())
try {
HttpUrl.Builder().host(domain).scheme("https").build()
} catch (e: IllegalArgumentException) {
setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_invalid_domain)
return
}
setLoading(true)
lifecycleScope.launch {
val credentials: AppCredentials = try {
mastodonApi.authenticateApp(
domain, getString(R.string.app_name), oauthRedirectUri,
OAUTH_SCOPES, getString(R.string.tusky_website)
)
} catch (e: Exception) {
binding.loginButton.isEnabled = true
binding.domainTextInputLayout.error =
getString(R.string.error_failed_app_registration)
setLoading(false)
Log.e(TAG, Log.getStackTraceString(e))
return@launch
}
// Before we open browser page we save the data.
// Even if we don't open other apps user may go to password manager or somewhere else
// and we will need to pick up the process where we left off.
// Alternatively we could pass it all as part of the intent and receive it back
// but it is a bit of a workaround.
preferences.edit()
.putString(DOMAIN, domain)
.putString(CLIENT_ID, credentials.clientId)
.putString(CLIENT_SECRET, credentials.clientSecret)
.apply()
redirectUserToAuthorizeAndLogin(domain, credentials.clientId)
}
}
private fun redirectUserToAuthorizeAndLogin(domain: String, clientId: String) {
// To authorize this app and log in it's necessary to redirect to the domain given,
// login there, and the server will redirect back to the app with its response.
val url = HttpUrl.Builder()
.scheme("https")
.host(domain)
.addPathSegments(MastodonApi.ENDPOINT_AUTHORIZE)
.addQueryParameter("client_id", clientId)
.addQueryParameter("redirect_uri", oauthRedirectUri)
.addQueryParameter("response_type", "code")
.addQueryParameter("scope", OAUTH_SCOPES)
.build()
doWebViewAuth.launch(LoginData(url.toString().toUri(), oauthRedirectUri.toUri()))
}
override fun onStart() {
super.onStart()
// first show or user cancelled login
setLoading(false)
}
private suspend fun fetchOauthToken(code: String) {
/* restore variables from SharedPreferences */
val domain = preferences.getNonNullString(DOMAIN, "")
val clientId = preferences.getNonNullString(CLIENT_ID, "")
val clientSecret = preferences.getNonNullString(CLIENT_SECRET, "")
setLoading(true)
val accessToken = try {
mastodonApi.fetchOAuthToken(
domain, clientId, clientSecret, oauthRedirectUri, code,
"authorization_code"
)
} catch (e: Exception) {
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),
)
return
}
accountManager.addAccount(accessToken.accessToken, domain)
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
finish()
overridePendingTransition(R.anim.explode, R.anim.explode)
}
private fun setLoading(loadingState: Boolean) {
if (loadingState) {
binding.loginLoadingLayout.visibility = View.VISIBLE
binding.loginInputLayout.visibility = View.GONE
} else {
binding.loginLoadingLayout.visibility = View.GONE
binding.loginInputLayout.visibility = View.VISIBLE
binding.loginButton.isEnabled = true
}
}
private fun isAdditionalLogin(): Boolean {
return intent.getBooleanExtra(LOGIN_MODE, false)
}
companion object {
private const val TAG = "LoginActivity" // logging tag
private const val OAUTH_SCOPES = "read write follow"
private const val LOGIN_MODE = "LOGIN_MODE"
private const val DOMAIN = "domain"
private const val CLIENT_ID = "clientId"
private const val CLIENT_SECRET = "clientSecret"
@JvmStatic
fun getIntent(context: Context, mode: Boolean): Intent {
val loginIntent = Intent(context, LoginActivity::class.java)
loginIntent.putExtra(LOGIN_MODE, mode)
return loginIntent
}
/** Make sure the user-entered text is just a fully-qualified domain name. */
private fun canonicalizeDomain(domain: String): String {
// Strip any schemes out.
var s = domain.replaceFirst("http://", "")
s = s.replaceFirst("https://", "")
// If a username was included (e.g. username@example.com), just take what's after the '@'.
val at = s.lastIndexOf('@')
if (at != -1) {
s = s.substring(at + 1)
}
return s.trim { it <= ' ' }
}
}
}

View File

@ -0,0 +1,162 @@
package com.keylesspalace.tusky.components.login
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import android.util.Log
import android.webkit.CookieManager
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebStorage
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.result.contract.ActivityResultContract
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.databinding.LoginWebviewBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.util.viewBinding
import kotlinx.parcelize.Parcelize
/** Contract for starting [LoginWebViewActivity]. */
class OauthLogin : ActivityResultContract<LoginData, LoginResult>() {
override fun createIntent(context: Context, input: LoginData): Intent {
val intent = Intent(context, LoginWebViewActivity::class.java)
intent.putExtra(DATA_EXTRA, input)
return intent
}
override fun parseResult(resultCode: Int, intent: Intent?): LoginResult {
// Can happen automatically on up or back press
return if (resultCode == Activity.RESULT_CANCELED) {
LoginResult.Cancel
} else {
intent!!.getParcelableExtra(RESULT_EXTRA)!!
}
}
companion object {
private const val RESULT_EXTRA = "result"
private const val DATA_EXTRA = "data"
fun parseData(intent: Intent): LoginData {
return intent.getParcelableExtra(DATA_EXTRA)!!
}
fun makeResultIntent(result: LoginResult): Intent {
val intent = Intent()
intent.putExtra(RESULT_EXTRA, result)
return intent
}
}
}
@Parcelize
data class LoginData(
val url: Uri,
val oauthRedirectUrl: Uri,
) : Parcelable
sealed class LoginResult : Parcelable {
@Parcelize
data class Ok(val code: String) : LoginResult()
@Parcelize
data class Err(val errorMessage: String) : LoginResult()
@Parcelize
object Cancel : LoginResult()
}
/** Activity to do Oauth process using WebView. */
class LoginWebViewActivity : BaseActivity(), Injectable {
private val binding by viewBinding(LoginWebviewBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val data = OauthLogin.parseData(intent)
setContentView(binding.root)
setSupportActionBar(binding.loginToolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowTitleEnabled(false)
val webView = binding.loginWebView
webView.settings.allowContentAccess = false
webView.settings.allowFileAccess = false
webView.settings.databaseEnabled = false
webView.settings.displayZoomControls = false
webView.settings.javaScriptCanOpenWindowsAutomatically = false
// Javascript needs to be enabled because otherwise 2FA does not work in some instances
@SuppressLint("SetJavaScriptEnabled")
webView.settings.javaScriptEnabled = true
webView.settings.userAgentString += " Tusky/${BuildConfig.VERSION_NAME}"
val oauthUrl = data.oauthRedirectUrl
webView.webViewClient = object : WebViewClient() {
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError
) {
Log.d("LoginWeb", "Failed to load ${data.url}: $error")
finish()
}
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest
): Boolean {
val url = request.url
return if (url.scheme == oauthUrl.scheme && url.host == oauthUrl.host) {
val error = url.getQueryParameter("error")
if (error != null) {
sendResult(LoginResult.Err(error))
} else {
val code = url.getQueryParameter("code").orEmpty()
sendResult(LoginResult.Ok(code))
}
true
} else {
false
}
}
}
webView.setBackgroundColor(Color.TRANSPARENT)
if (savedInstanceState == null) {
webView.loadUrl(data.url.toString())
} else {
webView.restoreState(savedInstanceState)
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
binding.loginWebView.saveState(outState)
}
override fun onDestroy() {
if (isFinishing) {
// We don't want to keep user session in WebView, we just want our own accessToken
WebStorage.getInstance().deleteAllData()
CookieManager.getInstance().removeAllCookies(null)
}
super.onDestroy()
}
override fun requiresLogin() = false
private fun sendResult(result: LoginResult) {
setResult(Activity.RESULT_OK, OauthLogin.makeResultIntent(result))
finish()
}
}

View File

@ -24,7 +24,6 @@ import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.graphics.BitmapFactory; import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.os.Build; import android.os.Build;
import android.provider.Settings; import android.provider.Settings;
import android.text.TextUtils; import android.text.TextUtils;
@ -46,9 +45,9 @@ import androidx.work.WorkRequest;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import com.bumptech.glide.load.resource.bitmap.RoundedCorners; import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
import com.bumptech.glide.request.FutureTarget; import com.bumptech.glide.request.FutureTarget;
import com.keylesspalace.tusky.BuildConfig;
import com.keylesspalace.tusky.MainActivity; import com.keylesspalace.tusky.MainActivity;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.components.compose.ComposeActivity;
import com.keylesspalace.tusky.db.AccountEntity; import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Notification;
@ -67,6 +66,7 @@ import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -88,8 +88,6 @@ public class NotificationHelper {
public static final String REPLY_ACTION = "REPLY_ACTION"; public static final String REPLY_ACTION = "REPLY_ACTION";
public static final String COMPOSE_ACTION = "COMPOSE_ACTION";
public static final String KEY_REPLY = "KEY_REPLY"; public static final String KEY_REPLY = "KEY_REPLY";
public static final String KEY_SENDER_ACCOUNT_ID = "KEY_SENDER_ACCOUNT_ID"; public static final String KEY_SENDER_ACCOUNT_ID = "KEY_SENDER_ACCOUNT_ID";
@ -108,10 +106,6 @@ public class NotificationHelper {
public static final String KEY_MENTIONS = "KEY_MENTIONS"; public static final String KEY_MENTIONS = "KEY_MENTIONS";
public static final String KEY_CITED_TEXT = "KEY_CITED_TEXT";
public static final String KEY_CITED_AUTHOR_LOCAL = "KEY_CITED_AUTHOR_LOCAL";
/** /**
* notification channels used on Android O+ * notification channels used on Android O+
**/ **/
@ -206,21 +200,24 @@ public class NotificationHelper {
.setLabel(context.getString(R.string.label_quick_reply)) .setLabel(context.getString(R.string.label_quick_reply))
.build(); .build();
PendingIntent quickReplyPendingIntent = getStatusReplyIntent(REPLY_ACTION, context, body, account); PendingIntent quickReplyPendingIntent = getStatusReplyIntent(context, body, account);
NotificationCompat.Action quickReplyAction = NotificationCompat.Action quickReplyAction =
new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp, new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
context.getString(R.string.action_quick_reply), quickReplyPendingIntent) context.getString(R.string.action_quick_reply),
quickReplyPendingIntent)
.addRemoteInput(replyRemoteInput) .addRemoteInput(replyRemoteInput)
.build(); .build();
builder.addAction(quickReplyAction); builder.addAction(quickReplyAction);
PendingIntent composePendingIntent = getStatusReplyIntent(COMPOSE_ACTION, context, body, account); PendingIntent composeIntent = getStatusComposeIntent(context, body, account);
NotificationCompat.Action composeAction = NotificationCompat.Action composeAction =
new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp, new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
context.getString(R.string.action_compose_shortcut), composePendingIntent) context.getString(R.string.action_compose_shortcut),
composeIntent)
.setShowsUserInterface(true)
.build(); .build();
builder.addAction(composeAction); builder.addAction(composeAction);
@ -237,7 +234,6 @@ public class NotificationHelper {
} }
// Summary // Summary
// =======
final NotificationCompat.Builder summaryBuilder = newNotification(context, body, account, true); final NotificationCompat.Builder summaryBuilder = newNotification(context, body, account, true);
if (currentNotifications.length() != 1) { if (currentNotifications.length() != 1) {
@ -275,7 +271,7 @@ public class NotificationHelper {
summaryStackBuilder.addNextIntent(summaryResultIntent); summaryStackBuilder.addNextIntent(summaryResultIntent);
PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent((int) (notificationId + account.getId() * 10000), PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent((int) (notificationId + account.getId() * 10000),
PendingIntent.FLAG_UPDATE_CURRENT); pendingIntentFlags(false));
// we have to switch account here // we have to switch account here
Intent eventResultIntent = new Intent(context, MainActivity.class); Intent eventResultIntent = new Intent(context, MainActivity.class);
@ -285,18 +281,18 @@ public class NotificationHelper {
eventStackBuilder.addNextIntent(eventResultIntent); eventStackBuilder.addNextIntent(eventResultIntent);
PendingIntent eventResultPendingIntent = eventStackBuilder.getPendingIntent((int) account.getId(), PendingIntent eventResultPendingIntent = eventStackBuilder.getPendingIntent((int) account.getId(),
PendingIntent.FLAG_UPDATE_CURRENT); pendingIntentFlags(false));
Intent deleteIntent = new Intent(context, NotificationClearBroadcastReceiver.class); Intent deleteIntent = new Intent(context, NotificationClearBroadcastReceiver.class);
deleteIntent.putExtra(ACCOUNT_ID, account.getId()); deleteIntent.putExtra(ACCOUNT_ID, account.getId());
PendingIntent deletePendingIntent = PendingIntent.getBroadcast(context, summary ? (int) account.getId() : notificationId, deleteIntent, PendingIntent deletePendingIntent = PendingIntent.getBroadcast(context, summary ? (int) account.getId() : notificationId, deleteIntent,
PendingIntent.FLAG_UPDATE_CURRENT); pendingIntentFlags(false));
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, getChannelId(account, body)) NotificationCompat.Builder builder = new NotificationCompat.Builder(context, getChannelId(account, body))
.setSmallIcon(R.drawable.ic_notify) .setSmallIcon(R.drawable.ic_notify)
.setContentIntent(summary ? summaryResultPendingIntent : eventResultPendingIntent) .setContentIntent(summary ? summaryResultPendingIntent : eventResultPendingIntent)
.setDeleteIntent(deletePendingIntent) .setDeleteIntent(deletePendingIntent)
.setColor(BuildConfig.FLAVOR == "green" ? Color.parseColor("#19A341") : ContextCompat.getColor(context, R.color.tusky_blue)) .setColor(ContextCompat.getColor(context, R.color.notification_color))
.setGroup(account.getAccountId()) .setGroup(account.getAccountId())
.setAutoCancel(true) .setAutoCancel(true)
.setShortcutId(Long.toString(account.getId())) .setShortcutId(Long.toString(account.getId()))
@ -307,11 +303,9 @@ public class NotificationHelper {
return builder; return builder;
} }
private static PendingIntent getStatusReplyIntent(String action, Context context, Notification body, AccountEntity account) { private static PendingIntent getStatusReplyIntent(Context context, Notification body, AccountEntity account) {
Status status = body.getStatus(); Status status = body.getStatus();
String citedLocalAuthor = status.getAccount().getLocalUsername();
String citedText = status.getContent().toString();
String inReplyToId = status.getId(); String inReplyToId = status.getId();
Status actionableStatus = status.getActionableStatus(); Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility(); Status.Visibility replyVisibility = actionableStatus.getVisibility();
@ -326,9 +320,7 @@ public class NotificationHelper {
mentionedUsernames = new ArrayList<>(new LinkedHashSet<>(mentionedUsernames)); mentionedUsernames = new ArrayList<>(new LinkedHashSet<>(mentionedUsernames));
Intent replyIntent = new Intent(context, SendStatusBroadcastReceiver.class) Intent replyIntent = new Intent(context, SendStatusBroadcastReceiver.class)
.setAction(action) .setAction(REPLY_ACTION)
.putExtra(KEY_CITED_AUTHOR_LOCAL, citedLocalAuthor)
.putExtra(KEY_CITED_TEXT, citedText)
.putExtra(KEY_SENDER_ACCOUNT_ID, account.getId()) .putExtra(KEY_SENDER_ACCOUNT_ID, account.getId())
.putExtra(KEY_SENDER_ACCOUNT_IDENTIFIER, account.getIdentifier()) .putExtra(KEY_SENDER_ACCOUNT_IDENTIFIER, account.getIdentifier())
.putExtra(KEY_SENDER_ACCOUNT_FULL_NAME, account.getFullName()) .putExtra(KEY_SENDER_ACCOUNT_FULL_NAME, account.getFullName())
@ -341,7 +333,50 @@ public class NotificationHelper {
return PendingIntent.getBroadcast(context.getApplicationContext(), return PendingIntent.getBroadcast(context.getApplicationContext(),
notificationId, notificationId,
replyIntent, replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT); pendingIntentFlags(true));
}
private static PendingIntent getStatusComposeIntent(Context context, Notification body, AccountEntity account) {
Status status = body.getStatus();
String citedLocalAuthor = status.getAccount().getLocalUsername();
String citedText = status.getContent().toString();
String inReplyToId = status.getId();
Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility();
String contentWarning = actionableStatus.getSpoilerText();
List<Status.Mention> mentions = actionableStatus.getMentions();
Set<String> mentionedUsernames = new LinkedHashSet<>();
mentionedUsernames.add(actionableStatus.getAccount().getUsername());
for (Status.Mention mention : mentions) {
String mentionedUsername = mention.getUsername();
if (!mentionedUsername.equals(account.getUsername())) {
mentionedUsernames.add(mention.getUsername());
}
}
ComposeActivity.ComposeOptions composeOptions = new ComposeActivity.ComposeOptions();
composeOptions.setInReplyToId(inReplyToId);
composeOptions.setReplyVisibility(replyVisibility);
composeOptions.setContentWarning(contentWarning);
composeOptions.setReplyingStatusAuthor(citedLocalAuthor);
composeOptions.setReplyingStatusContent(citedText);
composeOptions.setMentionedUsernames(mentionedUsernames);
composeOptions.setModifiedInitialState(true);
Intent composeIntent = ComposeActivity.startIntent(
context,
composeOptions,
notificationId,
account.getId()
);
composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
return PendingIntent.getActivity(context.getApplicationContext(),
notificationId,
composeIntent,
pendingIntentFlags(false));
} }
public static void createNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) { public static void createNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) {
@ -409,9 +444,7 @@ public class NotificationHelper {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
//noinspection ConstantConditions
notificationManager.deleteNotificationChannelGroup(account.getIdentifier()); notificationManager.deleteNotificationChannelGroup(account.getIdentifier());
} }
} }
@ -421,7 +454,6 @@ public class NotificationHelper {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
// used until Tusky 1.4 // used until Tusky 1.4
//noinspection ConstantConditions
notificationManager.deleteNotificationChannel(CHANNEL_MENTION); notificationManager.deleteNotificationChannel(CHANNEL_MENTION);
notificationManager.deleteNotificationChannel(CHANNEL_FAVOURITE); notificationManager.deleteNotificationChannel(CHANNEL_FAVOURITE);
notificationManager.deleteNotificationChannel(CHANNEL_BOOST); notificationManager.deleteNotificationChannel(CHANNEL_BOOST);
@ -440,7 +472,6 @@ public class NotificationHelper {
// on Android >= O, notifications are enabled, if at least one channel is enabled // on Android >= O, notifications are enabled, if at least one channel is enabled
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
//noinspection ConstantConditions
if (notificationManager.areNotificationsEnabled()) { if (notificationManager.areNotificationsEnabled()) {
for (NotificationChannel channel : notificationManager.getNotificationChannels()) { for (NotificationChannel channel : notificationManager.getNotificationChannels()) {
if (channel.getImportance() > NotificationManager.IMPORTANCE_NONE) { if (channel.getImportance() > NotificationManager.IMPORTANCE_NONE) {
@ -491,7 +522,6 @@ public class NotificationHelper {
accountManager.saveAccount(account); accountManager.saveAccount(account);
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
//noinspection ConstantConditions
notificationManager.cancel((int) account.getId()); notificationManager.cancel((int) account.getId());
return true; return true;
}) })
@ -511,7 +541,6 @@ public class NotificationHelper {
// unknown notificationtype // unknown notificationtype
return false; return false;
} }
//noinspection ConstantConditions
NotificationChannel channel = notificationManager.getNotificationChannel(channelId); NotificationChannel channel = notificationManager.getNotificationChannel(channelId);
return channel.getImportance() > NotificationManager.IMPORTANCE_NONE; return channel.getImportance() > NotificationManager.IMPORTANCE_NONE;
} }
@ -674,4 +703,11 @@ public class NotificationHelper {
return null; return null;
} }
public static int pendingIntentFlags(boolean mutable) {
if (mutable) {
return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0);
} else {
return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
}
}
} }

View File

@ -13,8 +13,9 @@ import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.SplashActivity import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.databinding.DialogEmojicompatBinding import com.keylesspalace.tusky.databinding.DialogEmojicompatBinding
import com.keylesspalace.tusky.databinding.ItemEmojiPrefBinding import com.keylesspalace.tusky.databinding.ItemEmojiPrefBinding
import com.keylesspalace.tusky.util.EmojiCompatFont import com.keylesspalace.tusky.util.EmojiCompatFont
@ -215,12 +216,12 @@ class EmojiPreference(
.setPositiveButton(R.string.restart) { _, _ -> .setPositiveButton(R.string.restart) { _, _ ->
// Restart the app // Restart the app
// From https://stackoverflow.com/a/17166729/5070653 // From https://stackoverflow.com/a/17166729/5070653
val launchIntent = Intent(context, SplashActivity::class.java) val launchIntent = Intent(context, MainActivity::class.java)
val mPendingIntent = PendingIntent.getActivity( val mPendingIntent = PendingIntent.getActivity(
context, context,
0x1f973, // This is the codepoint of the party face emoji :D 0x1f973, // This is the codepoint of the party face emoji :D
launchIntent, launchIntent,
PendingIntent.FLAG_CANCEL_CURRENT NotificationHelper.pendingIntentFlags(false)
) )
val mgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager val mgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
mgr.set( mgr.set(

View File

@ -78,7 +78,7 @@ class PreferencesActivity :
NotificationPreferencesFragment.newInstance() NotificationPreferencesFragment.newInstance()
} }
TAB_FILTER_PREFERENCES -> { TAB_FILTER_PREFERENCES -> {
setTitle(R.string.pref_title_status_tabs) setTitle(R.string.pref_title_post_tabs)
TabFilterPreferencesFragment.newInstance() TabFilterPreferencesFragment.newInstance()
} }
PROXY_PREFERENCES -> { PROXY_PREFERENCES -> {

View File

@ -25,7 +25,14 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.settings.* import com.keylesspalace.tusky.settings.AppTheme
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.settings.emojiPreference
import com.keylesspalace.tusky.settings.listPreference
import com.keylesspalace.tusky.settings.makePreferenceScreen
import com.keylesspalace.tusky.settings.preference
import com.keylesspalace.tusky.settings.preferenceCategory
import com.keylesspalace.tusky.settings.switchPreference
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.deserialize import com.keylesspalace.tusky.util.deserialize
import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.getNonNullString
@ -85,11 +92,11 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
listPreference { listPreference {
setDefaultValue("medium") setDefaultValue("medium")
setEntries(R.array.status_text_size_names) setEntries(R.array.post_text_size_names)
setEntryValues(R.array.status_text_size_values) setEntryValues(R.array.post_text_size_values)
key = PrefKeys.STATUS_TEXT_SIZE key = PrefKeys.STATUS_TEXT_SIZE
setSummaryProvider { entry } setSummaryProvider { entry }
setTitle(R.string.pref_status_text_size) setTitle(R.string.pref_post_text_size)
icon = makeIcon(GoogleMaterial.Icon.gmd_format_size) icon = makeIcon(GoogleMaterial.Icon.gmd_format_size)
} }
@ -137,6 +144,13 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
isSingleLineTitle = false isSingleLineTitle = false
} }
switchPreference {
setDefaultValue(false)
key = PrefKeys.ANIMATE_CUSTOM_EMOJIS
setTitle(R.string.pref_title_animate_custom_emojis)
isSingleLineTitle = false
}
switchPreference { switchPreference {
setDefaultValue(true) setDefaultValue(true)
key = PrefKeys.USE_BLURHASH key = PrefKeys.USE_BLURHASH
@ -179,13 +193,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
isSingleLineTitle = false isSingleLineTitle = false
} }
switchPreference {
setDefaultValue(false)
key = PrefKeys.ANIMATE_CUSTOM_EMOJIS
setTitle(R.string.pref_title_animate_custom_emojis)
isSingleLineTitle = false
}
switchPreference { switchPreference {
setDefaultValue(true) setDefaultValue(true)
key = PrefKeys.USE_QUICK_TOOT key = PrefKeys.USE_QUICK_TOOT
@ -232,7 +239,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
preferenceCategory(R.string.pref_title_timeline_filters) { preferenceCategory(R.string.pref_title_timeline_filters) {
preference { preference {
setTitle(R.string.pref_title_status_tabs) setTitle(R.string.pref_title_post_tabs)
setOnPreferenceClickListener { setOnPreferenceClickListener {
activity?.let { activity -> activity?.let { activity ->
val intent = PreferencesActivity.newIntent(activity, PreferencesActivity.TAB_FILTER_PREFERENCES) val intent = PreferencesActivity.newIntent(activity, PreferencesActivity.TAB_FILTER_PREFERENCES)
@ -305,7 +312,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
} }
} }
preferenceManager.sharedPreferences.let { prefs -> preferenceManager.sharedPreferences?.let { prefs ->
prefs.getString(PrefKeys.STACK_TRACE, null)?.let { stackTrace -> prefs.getString(PrefKeys.STACK_TRACE, null)?.let { stackTrace ->
preferenceCategory(R.string.pref_title_stacktrace) { preferenceCategory(R.string.pref_title_stacktrace) {
preference { preference {
@ -313,7 +320,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
setOnPreferenceClickListener { setOnPreferenceClickListener {
activity?.let { activity -> activity?.let { activity ->
val intent = ComposeActivity.startIntent(activity, ComposeOptions( val intent = ComposeActivity.startIntent(activity, ComposeOptions(
tootText = "@ars42525@odakyu.app $stackTrace".substring(0, 400), content = "@ars42525@odakyu.app $stackTrace".substring(0, 400),
contentWarning = "Yuito StackTrace" contentWarning = "Yuito StackTrace"
)) ))
activity.startActivity(intent) activity.startActivity(intent)
@ -350,23 +357,24 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
} }
private fun updateHttpProxySummary() { private fun updateHttpProxySummary() {
val sharedPreferences = preferenceManager.sharedPreferences preferenceManager.sharedPreferences?.let { sharedPreferences ->
val httpProxyEnabled = sharedPreferences.getBoolean(PrefKeys.HTTP_PROXY_ENABLED, false) val httpProxyEnabled = sharedPreferences.getBoolean(PrefKeys.HTTP_PROXY_ENABLED, false)
val httpServer = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_SERVER, "") val httpServer = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_SERVER, "")
try { try {
val httpPort = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_PORT, "-1") val httpPort = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_PORT, "-1")
.toInt() .toInt()
if (httpProxyEnabled && httpServer.isNotBlank() && httpPort > 0 && httpPort < 65535) { if (httpProxyEnabled && httpServer.isNotBlank() && httpPort > 0 && httpPort < 65535) {
httpProxyPref?.summary = "$httpServer:$httpPort" httpProxyPref?.summary = "$httpServer:$httpPort"
return return
}
} catch (e: NumberFormatException) {
// user has entered wrong port, fall back to empty summary
} }
} catch (e: NumberFormatException) {
// user has entered wrong port, fall back to empty summary
}
httpProxyPref?.summary = "" httpProxyPref?.summary = ""
}
} }
companion object { companion object {

View File

@ -123,9 +123,9 @@ class StatusViewHolder(
private fun setContentWarningButtonText(contentShown: Boolean) { private fun setContentWarningButtonText(contentShown: Boolean) {
if (contentShown) { if (contentShown) {
binding.statusContentWarningButton.setText(R.string.status_content_warning_show_less) binding.statusContentWarningButton.setText(R.string.post_content_warning_show_less)
} else { } else {
binding.statusContentWarningButton.setText(R.string.status_content_warning_show_more) binding.statusContentWarningButton.setText(R.string.post_content_warning_show_more)
} }
} }
@ -178,10 +178,10 @@ class StatusViewHolder(
binding.buttonToggleContent.show() binding.buttonToggleContent.show()
if (collapsed) { if (collapsed) {
binding.buttonToggleContent.setText(R.string.status_content_show_more) binding.buttonToggleContent.setText(R.string.post_content_show_more)
binding.statusContent.filters = COLLAPSE_INPUT_FILTER binding.statusContent.filters = COLLAPSE_INPUT_FILTER
} else { } else {
binding.buttonToggleContent.setText(R.string.status_content_show_less) binding.buttonToggleContent.setText(R.string.post_content_show_less)
binding.statusContent.filters = NO_INPUT_FILTER binding.statusContent.filters = NO_INPUT_FILTER
} }
} else { } else {

View File

@ -154,7 +154,7 @@ class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Inje
private fun showError() { private fun showError() {
if (snackbarErrorRetry?.isShown != true) { if (snackbarErrorRetry?.isShown != true) {
snackbarErrorRetry = Snackbar.make(binding.swipeRefreshLayout, R.string.failed_fetch_statuses, Snackbar.LENGTH_INDEFINITE) snackbarErrorRetry = Snackbar.make(binding.swipeRefreshLayout, R.string.failed_fetch_posts, Snackbar.LENGTH_INDEFINITE)
snackbarErrorRetry?.setAction(R.string.action_retry) { snackbarErrorRetry?.setAction(R.string.action_retry) {
adapter.retry() adapter.retry()
} }

View File

@ -29,7 +29,7 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.StatusScheduledEvent import com.keylesspalace.tusky.appstore.StatusScheduledEvent
import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.databinding.ActivityScheduledTootBinding import com.keylesspalace.tusky.databinding.ActivityScheduledStatusBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.entity.ScheduledStatus
@ -40,7 +40,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injectable { class ScheduledStatusActivity : BaseActivity(), ScheduledStatusActionListener, Injectable {
@Inject @Inject
lateinit var viewModelFactory: ViewModelFactory lateinit var viewModelFactory: ViewModelFactory
@ -48,19 +48,19 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec
@Inject @Inject
lateinit var eventHub: EventHub lateinit var eventHub: EventHub
private val viewModel: ScheduledTootViewModel by viewModels { viewModelFactory } private val viewModel: ScheduledStatusViewModel by viewModels { viewModelFactory }
private val adapter = ScheduledTootAdapter(this) private val adapter = ScheduledStatusAdapter(this)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val binding = ActivityScheduledTootBinding.inflate(layoutInflater) val binding = ActivityScheduledStatusBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar) setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.run { supportActionBar?.run {
title = getString(R.string.title_scheduled_toot) title = getString(R.string.title_scheduled_posts)
setDisplayHomeAsUpEnabled(true) setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true) setDisplayShowHomeEnabled(true)
} }
@ -94,7 +94,7 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec
if (loadState.refresh is LoadState.NotLoading) { if (loadState.refresh is LoadState.NotLoading) {
binding.progressBar.hide() binding.progressBar.hide()
if (adapter.itemCount == 0) { if (adapter.itemCount == 0) {
binding.errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_scheduled_status) binding.errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_scheduled_posts)
binding.errorMessageView.show() binding.errorMessageView.show()
} else { } else {
binding.errorMessageView.hide() binding.errorMessageView.hide()
@ -121,7 +121,7 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec
this, this,
ComposeActivity.ComposeOptions( ComposeActivity.ComposeOptions(
scheduledTootId = item.id, scheduledTootId = item.id,
tootText = item.params.text, content = item.params.text,
contentWarning = item.params.spoilerText, contentWarning = item.params.spoilerText,
mediaAttachments = item.mediaAttachments, mediaAttachments = item.mediaAttachments,
inReplyToId = item.params.inReplyToId, inReplyToId = item.params.inReplyToId,
@ -138,6 +138,6 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec
} }
companion object { companion object {
fun newIntent(context: Context) = Intent(context, ScheduledTootActivity::class.java) fun newIntent(context: Context) = Intent(context, ScheduledStatusActivity::class.java)
} }
} }

View File

@ -20,18 +20,18 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.paging.PagingDataAdapter import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import com.keylesspalace.tusky.databinding.ItemScheduledTootBinding import com.keylesspalace.tusky.databinding.ItemScheduledStatusBinding
import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.entity.ScheduledStatus
import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.BindingHolder
interface ScheduledTootActionListener { interface ScheduledStatusActionListener {
fun edit(item: ScheduledStatus) fun edit(item: ScheduledStatus)
fun delete(item: ScheduledStatus) fun delete(item: ScheduledStatus)
} }
class ScheduledTootAdapter( class ScheduledStatusAdapter(
val listener: ScheduledTootActionListener val listener: ScheduledStatusActionListener
) : PagingDataAdapter<ScheduledStatus, BindingHolder<ItemScheduledTootBinding>>( ) : PagingDataAdapter<ScheduledStatus, BindingHolder<ItemScheduledStatusBinding>>(
object : DiffUtil.ItemCallback<ScheduledStatus>() { object : DiffUtil.ItemCallback<ScheduledStatus>() {
override fun areItemsTheSame(oldItem: ScheduledStatus, newItem: ScheduledStatus): Boolean { override fun areItemsTheSame(oldItem: ScheduledStatus, newItem: ScheduledStatus): Boolean {
return oldItem.id == newItem.id return oldItem.id == newItem.id
@ -43,12 +43,12 @@ class ScheduledTootAdapter(
} }
) { ) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemScheduledTootBinding> { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemScheduledStatusBinding> {
val binding = ItemScheduledTootBinding.inflate(LayoutInflater.from(parent.context), parent, false) val binding = ItemScheduledStatusBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding) return BindingHolder(binding)
} }
override fun onBindViewHolder(holder: BindingHolder<ItemScheduledTootBinding>, position: Int) { override fun onBindViewHolder(holder: BindingHolder<ItemScheduledStatusBinding>, position: Int) {
getItem(position)?.let { item -> getItem(position)?.let { item ->
holder.binding.edit.isEnabled = true holder.binding.edit.isEnabled = true
holder.binding.delete.isEnabled = true holder.binding.delete.isEnabled = true

View File

@ -22,16 +22,16 @@ import com.keylesspalace.tusky.entity.ScheduledStatus
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
class ScheduledTootPagingSourceFactory( class ScheduledStatusPagingSourceFactory(
private val mastodonApi: MastodonApi private val mastodonApi: MastodonApi
) : () -> ScheduledTootPagingSource { ) : () -> ScheduledStatusPagingSource {
private val scheduledTootsCache = mutableListOf<ScheduledStatus>() private val scheduledTootsCache = mutableListOf<ScheduledStatus>()
private var pagingSource: ScheduledTootPagingSource? = null private var pagingSource: ScheduledStatusPagingSource? = null
override fun invoke(): ScheduledTootPagingSource { override fun invoke(): ScheduledStatusPagingSource {
return ScheduledTootPagingSource(mastodonApi, scheduledTootsCache).also { return ScheduledStatusPagingSource(mastodonApi, scheduledTootsCache).also {
pagingSource = it pagingSource = it
} }
} }
@ -42,9 +42,9 @@ class ScheduledTootPagingSourceFactory(
} }
} }
class ScheduledTootPagingSource( class ScheduledStatusPagingSource(
private val mastodonApi: MastodonApi, private val mastodonApi: MastodonApi,
private val scheduledTootsCache: MutableList<ScheduledStatus> private val scheduledStatusesCache: MutableList<ScheduledStatus>
) : PagingSource<String, ScheduledStatus>() { ) : PagingSource<String, ScheduledStatus>() {
override fun getRefreshKey(state: PagingState<String, ScheduledStatus>): String? { override fun getRefreshKey(state: PagingState<String, ScheduledStatus>): String? {
@ -52,11 +52,11 @@ class ScheduledTootPagingSource(
} }
override suspend fun load(params: LoadParams<String>): LoadResult<String, ScheduledStatus> { override suspend fun load(params: LoadParams<String>): LoadResult<String, ScheduledStatus> {
return if (params is LoadParams.Refresh && scheduledTootsCache.isNotEmpty()) { return if (params is LoadParams.Refresh && scheduledStatusesCache.isNotEmpty()) {
LoadResult.Page( LoadResult.Page(
data = scheduledTootsCache, data = scheduledStatusesCache,
prevKey = null, prevKey = null,
nextKey = scheduledTootsCache.lastOrNull()?.id nextKey = scheduledStatusesCache.lastOrNull()?.id
) )
} else { } else {
try { try {
@ -71,7 +71,7 @@ class ScheduledTootPagingSource(
nextKey = result.lastOrNull()?.id nextKey = result.lastOrNull()?.id
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.w("ScheduledTootPgngSrc", "Error loading scheduled statuses", e) Log.w("ScheduledStatuses", "Error loading scheduled statuses", e)
LoadResult.Error(e) LoadResult.Error(e)
} }
} }

View File

@ -28,12 +28,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
import javax.inject.Inject import javax.inject.Inject
class ScheduledTootViewModel @Inject constructor( class ScheduledStatusViewModel @Inject constructor(
val mastodonApi: MastodonApi, val mastodonApi: MastodonApi,
val eventHub: EventHub val eventHub: EventHub
) : ViewModel() { ) : ViewModel() {
private val pagingSourceFactory = ScheduledTootPagingSourceFactory(mastodonApi) private val pagingSourceFactory = ScheduledStatusPagingSourceFactory(mastodonApi)
val data = Pager( val data = Pager(
config = PagingConfig(pageSize = 20, initialLoadSize = 20), config = PagingConfig(pageSize = 20, initialLoadSize = 20),

View File

@ -87,7 +87,7 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector {
private fun getPageTitle(position: Int): CharSequence { private fun getPageTitle(position: Int): CharSequence {
return when (position) { return when (position) {
0 -> getString(R.string.title_statuses) 0 -> getString(R.string.title_posts)
1 -> getString(R.string.title_accounts) 1 -> getString(R.string.title_accounts)
2 -> getString(R.string.title_hashtags_dialog) 2 -> getString(R.string.title_hashtags_dialog)
3 -> getString(R.string.title_notestock) 3 -> getString(R.string.title_notestock)

View File

@ -21,11 +21,11 @@ import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.AccountViewHolder import com.keylesspalace.tusky.adapter.AccountViewHolder
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.LinkListener
class SearchAccountsAdapter(private val linkListener: LinkListener, private val animateAvatars: Boolean, private val animateEmojis: Boolean) : class SearchAccountsAdapter(private val linkListener: LinkListener, private val animateAvatars: Boolean, private val animateEmojis: Boolean) :
PagingDataAdapter<Account, AccountViewHolder>(ACCOUNT_COMPARATOR) { PagingDataAdapter<TimelineAccount, AccountViewHolder>(ACCOUNT_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder {
val view = LayoutInflater.from(parent.context) val view = LayoutInflater.from(parent.context)
@ -44,11 +44,11 @@ class SearchAccountsAdapter(private val linkListener: LinkListener, private val
companion object { companion object {
val ACCOUNT_COMPARATOR = object : DiffUtil.ItemCallback<Account>() { val ACCOUNT_COMPARATOR = object : DiffUtil.ItemCallback<TimelineAccount>() {
override fun areContentsTheSame(oldItem: Account, newItem: Account): Boolean = override fun areContentsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean =
oldItem.deepEquals(newItem) oldItem == newItem
override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean = override fun areItemsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean =
oldItem.id == newItem.id oldItem.id == newItem.id
} }
} }

View File

@ -19,12 +19,12 @@ import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter import androidx.paging.PagingDataAdapter
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
class SearchAccountsFragment : SearchFragment<Account>() { class SearchAccountsFragment : SearchFragment<TimelineAccount>() {
override fun createAdapter(): PagingDataAdapter<Account, *> { override fun createAdapter(): PagingDataAdapter<TimelineAccount, *> {
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context) val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
return SearchAccountsAdapter( return SearchAccountsAdapter(
@ -34,7 +34,7 @@ class SearchAccountsFragment : SearchFragment<Account>() {
) )
} }
override val data: Flow<PagingData<Account>> override val data: Flow<PagingData<TimelineAccount>>
get() = viewModel.accountsFlow get() = viewModel.accountsFlow
companion object { companion object {

View File

@ -300,7 +300,7 @@ class SearchNotestockFragment : SearchFragment<StatusViewData.Concrete>(), Statu
popup.setOnMenuItemClickListener { item -> popup.setOnMenuItemClickListener { item ->
when (item.itemId) { when (item.itemId) {
R.id.status_share_content -> { R.id.post_share_content -> {
val statusToShare: Status = status.actionableStatus val statusToShare: Status = status.actionableStatus
val sendIntent = Intent() val sendIntent = Intent()
@ -314,12 +314,12 @@ class SearchNotestockFragment : SearchFragment<StatusViewData.Concrete>(), Statu
startActivity( startActivity(
Intent.createChooser( Intent.createChooser(
sendIntent, sendIntent,
resources.getText(R.string.send_status_content_to) resources.getText(R.string.send_post_content_to)
) )
) )
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
} }
R.id.status_share_link -> { R.id.post_share_link -> {
val sendIntent = Intent() val sendIntent = Intent()
sendIntent.action = Intent.ACTION_SEND sendIntent.action = Intent.ACTION_SEND
sendIntent.putExtra(Intent.EXTRA_TEXT, statusUrl) sendIntent.putExtra(Intent.EXTRA_TEXT, statusUrl)
@ -327,7 +327,7 @@ class SearchNotestockFragment : SearchFragment<StatusViewData.Concrete>(), Statu
startActivity( startActivity(
Intent.createChooser( Intent.createChooser(
sendIntent, sendIntent,
resources.getText(R.string.send_status_link_to) resources.getText(R.string.send_post_link_to)
) )
) )
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
@ -456,7 +456,7 @@ class SearchNotestockFragment : SearchFragment<StatusViewData.Concrete>(), Statu
private fun showConfirmDeleteDialog(id: String, position: Int) { private fun showConfirmDeleteDialog(id: String, position: Int) {
context?.let { context?.let {
AlertDialog.Builder(it) AlertDialog.Builder(it)
.setMessage(R.string.dialog_delete_toot_warning) .setMessage(R.string.dialog_delete_post_warning)
.setPositiveButton(android.R.string.ok) { _, _ -> .setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.deleteStatus(id) viewModel.deleteStatus(id)
removeItem(position) removeItem(position)
@ -469,7 +469,7 @@ class SearchNotestockFragment : SearchFragment<StatusViewData.Concrete>(), Statu
private fun showConfirmEditDialog(id: String, position: Int, status: Status) { private fun showConfirmEditDialog(id: String, position: Int, status: Status) {
activity?.let { activity?.let {
AlertDialog.Builder(it) AlertDialog.Builder(it)
.setMessage(R.string.dialog_redraft_toot_warning) .setMessage(R.string.dialog_redraft_post_warning)
.setPositiveButton(android.R.string.ok) { _, _ -> .setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.deleteStatus(id) viewModel.deleteStatus(id)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
@ -486,7 +486,7 @@ class SearchNotestockFragment : SearchFragment<StatusViewData.Concrete>(), Statu
val intent = ComposeActivity.startIntent( val intent = ComposeActivity.startIntent(
requireContext(), requireContext(),
ComposeOptions( ComposeOptions(
tootText = redraftStatus.text ?: "", content = redraftStatus.text ?: "",
inReplyToId = redraftStatus.inReplyToId, inReplyToId = redraftStatus.inReplyToId,
visibility = redraftStatus.visibility, visibility = redraftStatus.visibility,
contentWarning = redraftStatus.spoilerText, contentWarning = redraftStatus.spoilerText,

View File

@ -310,7 +310,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
popup.setOnMenuItemClickListener { item -> popup.setOnMenuItemClickListener { item ->
when (item.itemId) { when (item.itemId) {
R.id.status_share_content -> { R.id.post_share_content -> {
val statusToShare: Status = status.actionableStatus val statusToShare: Status = status.actionableStatus
val sendIntent = Intent() val sendIntent = Intent()
@ -321,15 +321,15 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
statusToShare.content.toString() statusToShare.content.toString()
sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare) sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare)
sendIntent.type = "text/plain" sendIntent.type = "text/plain"
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_status_content_to))) startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_post_content_to)))
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
} }
R.id.status_share_link -> { R.id.post_share_link -> {
val sendIntent = Intent() val sendIntent = Intent()
sendIntent.action = Intent.ACTION_SEND sendIntent.action = Intent.ACTION_SEND
sendIntent.putExtra(Intent.EXTRA_TEXT, statusUrl) sendIntent.putExtra(Intent.EXTRA_TEXT, statusUrl)
sendIntent.type = "text/plain" sendIntent.type = "text/plain"
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_status_link_to))) startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_post_link_to)))
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
} }
R.id.status_copy_link -> { R.id.status_copy_link -> {
@ -454,7 +454,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
private fun showConfirmDeleteDialog(id: String, position: Int) { private fun showConfirmDeleteDialog(id: String, position: Int) {
context?.let { context?.let {
AlertDialog.Builder(it) AlertDialog.Builder(it)
.setMessage(R.string.dialog_delete_toot_warning) .setMessage(R.string.dialog_delete_post_warning)
.setPositiveButton(android.R.string.ok) { _, _ -> .setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.deleteStatus(id) viewModel.deleteStatus(id)
removeItem(position) removeItem(position)
@ -467,7 +467,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
private fun showConfirmEditDialog(id: String, position: Int, status: Status) { private fun showConfirmEditDialog(id: String, position: Int, status: Status) {
activity?.let { activity?.let {
AlertDialog.Builder(it) AlertDialog.Builder(it)
.setMessage(R.string.dialog_redraft_toot_warning) .setMessage(R.string.dialog_redraft_post_warning)
.setPositiveButton(android.R.string.ok) { _, _ -> .setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.deleteStatus(id) viewModel.deleteStatus(id)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
@ -485,7 +485,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
val intent = ComposeActivity.startIntent( val intent = ComposeActivity.startIntent(
requireContext(), requireContext(),
ComposeOptions( ComposeOptions(
tootText = redraftStatus.text ?: "", content = redraftStatus.text ?: "",
inReplyToId = redraftStatus.inReplyToId, inReplyToId = redraftStatus.inReplyToId,
visibility = redraftStatus.visibility, visibility = redraftStatus.visibility,
contentWarning = redraftStatus.spoilerText, contentWarning = redraftStatus.spoilerText,

View File

@ -90,9 +90,9 @@ class TimelineFragment :
private val viewModel: TimelineViewModel by lazy { private val viewModel: TimelineViewModel by lazy {
if (kind == TimelineViewModel.Kind.HOME) { if (kind == TimelineViewModel.Kind.HOME) {
ViewModelProvider(this, viewModelFactory).get(CachedTimelineViewModel::class.java) ViewModelProvider(this, viewModelFactory)[CachedTimelineViewModel::class.java]
} else { } else {
ViewModelProvider(this, viewModelFactory).get(NetworkTimelineViewModel::class.java) ViewModelProvider(this, viewModelFactory)[NetworkTimelineViewModel::class.java]
} }
} }
@ -140,7 +140,7 @@ class TimelineFragment :
isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true)
val preferences = PreferenceManager.getDefaultSharedPreferences(activity) val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val statusDisplayOptions = StatusDisplayOptions( val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled,
@ -187,7 +187,7 @@ class TimelineFragment :
if (adapter.itemCount == 0) { if (adapter.itemCount == 0) {
when (loadState.refresh) { when (loadState.refresh) {
is LoadState.NotLoading -> { is LoadState.NotLoading -> {
if (loadState.append is LoadState.NotLoading) { if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) {
binding.statusView.show() binding.statusView.show()
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
} }
@ -238,7 +238,7 @@ class TimelineFragment :
} }
if (actionButtonPresent()) { if (actionButtonPresent()) {
val preferences = PreferenceManager.getDefaultSharedPreferences(context) val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
hideFab = preferences.getBoolean("fabHide", false) hideFab = preferences.getBoolean("fabHide", false)
scrollListener = object : RecyclerView.OnScrollListener() { scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
@ -435,7 +435,7 @@ class TimelineFragment :
} }
private fun onPreferenceChanged(key: String) { private fun onPreferenceChanged(key: String) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
when (key) { when (key) {
PrefKeys.FAB_HIDE -> { PrefKeys.FAB_HIDE -> {
hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
@ -502,7 +502,7 @@ class TimelineFragment :
* Auto dispose observable on pause * Auto dispose observable on pause
*/ */
private fun startUpdateTimestamp() { private fun startUpdateTimestamp() {
val preferences = PreferenceManager.getDefaultSharedPreferences(activity) val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
if (!useAbsoluteTime) { if (!useAbsoluteTime) {
Observable.interval(1, TimeUnit.MINUTES) Observable.interval(1, TimeUnit.MINUTES)

View File

@ -114,7 +114,7 @@ class TimelinePagingAdapter(
oldItem: StatusViewData, oldItem: StatusViewData,
newItem: StatusViewData newItem: StatusViewData
): Boolean { ): Boolean {
return oldItem.viewDataId == newItem.viewDataId return oldItem.id == newItem.id
} }
override fun areContentsTheSame( override fun areContentsTheSame(
@ -128,7 +128,7 @@ class TimelinePagingAdapter(
oldItem: StatusViewData, oldItem: StatusViewData,
newItem: StatusViewData newItem: StatusViewData
): Any? { ): Any? {
return if (oldItem === newItem) { return if (oldItem == newItem) {
// If items are equal - update timestamp only // If items are equal - update timestamp only
listOf(StatusBaseViewHolder.Key.KEY_CREATED) listOf(StatusBaseViewHolder.Key.KEY_CREATED)
} else // If items are different - update the whole view holder } else // If items are different - update the whole view holder

View File

@ -23,12 +23,12 @@ import com.google.gson.reflect.TypeToken
import com.keylesspalace.tusky.db.TimelineAccountEntity import com.keylesspalace.tusky.db.TimelineAccountEntity
import com.keylesspalace.tusky.db.TimelineStatusEntity import com.keylesspalace.tusky.db.TimelineStatusEntity
import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.shouldTrimStatus import com.keylesspalace.tusky.util.shouldTrimStatus
import com.keylesspalace.tusky.util.trimTrailingWhitespace import com.keylesspalace.tusky.util.trimTrailingWhitespace
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
@ -44,7 +44,7 @@ private val emojisListType = object : TypeToken<List<Emoji>>() {}.type
private val mentionListType = object : TypeToken<List<Status.Mention>>() {}.type private val mentionListType = object : TypeToken<List<Status.Mention>>() {}.type
private val tagListType = object : TypeToken<List<HashTag>>() {}.type private val tagListType = object : TypeToken<List<HashTag>>() {}.type
fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity { fun TimelineAccount.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity {
return TimelineAccountEntity( return TimelineAccountEntity(
serverId = id, serverId = id,
timelineUserId = accountId, timelineUserId = accountId,
@ -58,25 +58,16 @@ fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity {
) )
} }
fun TimelineAccountEntity.toAccount(gson: Gson): Account { fun TimelineAccountEntity.toAccount(gson: Gson): TimelineAccount {
return Account( return TimelineAccount(
id = serverId, id = serverId,
localUsername = localUsername, localUsername = localUsername,
username = username, username = username,
displayName = displayName, displayName = displayName,
note = SpannedString(""),
url = url, url = url,
avatar = avatar, avatar = avatar,
header = "",
locked = false,
followingCount = 0,
followersCount = 0,
statusesCount = 0,
source = null,
bot = bot, bot = bot,
emojis = gson.fromJson(emojis, emojisListType), emojis = gson.fromJson(emojis, emojisListType)
fields = null,
moved = null
) )
} }

View File

@ -0,0 +1,17 @@
package com.keylesspalace.tusky.components.timeline.util
import retrofit2.HttpException
import java.io.IOException
fun Throwable.isExpected() = this is IOException || this is HttpException
inline fun <T> ifExpected(
t: Throwable,
cb: () -> T
): T {
if (t.isExpected()) {
return cb()
} else {
throw t
}
}

View File

@ -23,13 +23,13 @@ import androidx.room.withTransaction
import com.google.gson.Gson import com.google.gson.Gson
import com.keylesspalace.tusky.components.timeline.Placeholder import com.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.TimelineStatusEntity import com.keylesspalace.tusky.db.TimelineStatusEntity
import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.dec
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
import retrofit2.HttpException import retrofit2.HttpException
@ -101,15 +101,22 @@ class CachedTimelineRemoteMediator(
db.withTransaction { db.withTransaction {
val overlappedStatuses = replaceStatusRange(statuses, state) val overlappedStatuses = replaceStatusRange(statuses, state)
if (loadType == LoadType.REFRESH && overlappedStatuses == 0 && statuses.isNotEmpty() && !dbEmpty) { /* In case we loaded a whole page and there was no overlap with existing statuses,
we insert a placeholder because there might be even more unknown statuses */
if (loadType == LoadType.REFRESH && overlappedStatuses == 0 && statuses.size == state.config.pageSize && !dbEmpty) {
/* This overrides the last of the newly loaded statuses with a placeholder
to guarantee the placeholder has an id that exists on the server as not all
servers handle client generated ids as expected */
timelineDao.insertStatus( timelineDao.insertStatus(
Placeholder(statuses.last().id.dec(), loading = false).toEntity(activeAccount.id) Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id)
) )
} }
} }
return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty())
} catch (e: Exception) { } catch (e: Exception) {
return MediatorResult.Error(e) return ifExpected(e) {
MediatorResult.Error(e)
}
} }
} }

View File

@ -34,6 +34,7 @@ import com.keylesspalace.tusky.appstore.ReblogEvent
import com.keylesspalace.tusky.components.timeline.Placeholder import com.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.components.timeline.toViewData 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.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
@ -41,8 +42,6 @@ import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.dec
import com.keylesspalace.tusky.util.inc
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -151,9 +150,11 @@ class CachedTimelineViewModel @Inject constructor(
timelineDao.insertStatus(Placeholder(placeholderId, loading = true).toEntity(activeAccount.id)) timelineDao.insertStatus(Placeholder(placeholderId, loading = true).toEntity(activeAccount.id))
val nextPlaceholderId = timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId) val response = db.withTransaction {
val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId)
val response = api.homeTimeline(maxId = placeholderId.inc(), sinceId = nextPlaceholderId, limit = LOAD_AT_ONCE).await() val nextPlaceholderId = timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId)
api.homeTimeline(maxId = idAbovePlaceholder, sinceId = nextPlaceholderId, limit = LOAD_AT_ONCE)
}.await()
val statuses = response.body() val statuses = response.body()
if (!response.isSuccessful || statuses == null) { if (!response.isSuccessful || statuses == null) {
@ -187,14 +188,21 @@ class CachedTimelineViewModel @Inject constructor(
) )
} }
if (overlappedStatuses == 0 && statuses.isNotEmpty()) { /* In case we loaded a whole page and there was no overlap with existing statuses,
we insert a placeholder because there might be even more unknown statuses */
if (overlappedStatuses == 0 && statuses.size == LOAD_AT_ONCE) {
/* This overrides the last of the newly loaded statuses with a placeholder
to guarantee the placeholder has an id that exists on the server as not all
servers handle client generated ids as expected */
timelineDao.insertStatus( timelineDao.insertStatus(
Placeholder(statuses.last().id.dec(), loading = false).toEntity(activeAccount.id) Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id)
) )
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
loadMoreFailed(placeholderId, e) ifExpected(e) {
loadMoreFailed(placeholderId, e)
}
} }
} }
} }
@ -228,9 +236,9 @@ class CachedTimelineViewModel @Inject constructor(
db.withTransaction { db.withTransaction {
if (isFirstOfStreaming) { if (isFirstOfStreaming) {
val placeholderId = status.id.dec() timelineDao.insertStatus(Placeholder(status.id, loading = false).toEntity(activeAccount.id))
timelineDao.insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id))
isFirstOfStreaming = false isFirstOfStreaming = false
return@withTransaction
} }
timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson)) timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson))

View File

@ -19,9 +19,9 @@ import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType import androidx.paging.LoadType
import androidx.paging.PagingState import androidx.paging.PagingState
import androidx.paging.RemoteMediator import androidx.paging.RemoteMediator
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.util.HttpHeaderLink import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.dec
import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import retrofit2.HttpException import retrofit2.HttpException
@ -92,7 +92,7 @@ class NetworkTimelineRemoteMediator(
viewModel.statusData.addAll(0, data) viewModel.statusData.addAll(0, data)
if (insertPlaceholder) { if (insertPlaceholder) {
viewModel.statusData.add(statuses.size, StatusViewData.Placeholder(statuses.last().id.dec(), false)) viewModel.statusData[statuses.size - 1] = StatusViewData.Placeholder(statuses.last().id, false)
} }
} else { } else {
val linkHeader = statusResponse.headers()["Link"] val linkHeader = statusResponse.headers()["Link"]
@ -107,7 +107,9 @@ class NetworkTimelineRemoteMediator(
viewModel.currentSource?.invalidate() viewModel.currentSource?.invalidate()
return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty())
} catch (e: Exception) { } catch (e: Exception) {
return MediatorResult.Error(e) return ifExpected(e) {
MediatorResult.Error(e)
}
} }
} }
} }

View File

@ -28,15 +28,16 @@ import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.FavoriteEvent import com.keylesspalace.tusky.appstore.FavoriteEvent
import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.PinEvent
import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.appstore.ReblogEvent
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.dec
import com.keylesspalace.tusky.util.getDomain import com.keylesspalace.tusky.util.getDomain
import com.keylesspalace.tusky.util.inc import com.keylesspalace.tusky.util.isLessThan
import com.keylesspalace.tusky.util.isLessThanOrEqual
import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -45,6 +46,7 @@ import kotlinx.coroutines.rx3.await
import net.accelf.yuito.streaming.StreamingManager import net.accelf.yuito.streaming.StreamingManager
import retrofit2.HttpException import retrofit2.HttpException
import retrofit2.Response import retrofit2.Response
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -140,8 +142,10 @@ class NetworkTimelineViewModel @Inject constructor(
statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId } statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId }
statusData[placeholderIndex] = StatusViewData.Placeholder(placeholderId, isLoading = true) statusData[placeholderIndex] = StatusViewData.Placeholder(placeholderId, isLoading = true)
val idAbovePlaceholder = statusData.getOrNull(placeholderIndex - 1)?.id
val statusResponse = fetchStatusesForKind( val statusResponse = fetchStatusesForKind(
fromId = placeholderId.inc(), fromId = idAbovePlaceholder,
uptoId = null, uptoId = null,
limit = 20 limit = 20
) )
@ -155,7 +159,7 @@ class NetworkTimelineViewModel @Inject constructor(
statusData.removeAt(placeholderIndex) statusData.removeAt(placeholderIndex)
val activeAccount = accountManager.activeAccount!! val activeAccount = accountManager.activeAccount!!
val data = statuses.map { status -> val data: MutableList<StatusViewData> = statuses.map { status ->
status.toViewData( status.toViewData(
isShowingContent = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, isShowingContent = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
isExpanded = activeAccount.alwaysOpenSpoiler, isExpanded = activeAccount.alwaysOpenSpoiler,
@ -164,30 +168,31 @@ class NetworkTimelineViewModel @Inject constructor(
}.toMutableList() }.toMutableList()
if (statuses.isNotEmpty()) { if (statuses.isNotEmpty()) {
val firstId = statuses.first().id.hashCode().toLong() val firstId = statuses.first().id
val lastId = statuses.last().id.hashCode().toLong() val lastId = statuses.last().id
val overlappedFrom = statusData.indexOfFirst { it.viewDataId <= firstId } val overlappedFrom = statusData.indexOfFirst { it.asStatusOrNull()?.id?.isLessThanOrEqual(firstId) ?: false }
val overlappedTo = statusData.indexOfFirst { it.viewDataId < lastId } val overlappedTo = statusData.indexOfFirst { it.asStatusOrNull()?.id?.isLessThan(lastId) ?: false }
if (overlappedFrom < overlappedTo) { if (overlappedFrom < overlappedTo) {
repeat(overlappedTo - overlappedFrom) { data.mapIndexed { i, status -> i to statusData.firstOrNull { it.asStatusOrNull()?.id == status.id }?.asStatusOrNull() }
statusData[overlappedFrom].asStatusOrNull()?.let { oldStatus -> .filter { (_, oldStatus) -> oldStatus != null }
val dataIndex = statuses.indexOfFirst { it.id == oldStatus.id } .forEach { (i, oldStatus) ->
if (dataIndex == -1) { data[i] = data[i].asStatusOrNull()!!
return@let
}
data[dataIndex] = data[dataIndex]
.copy( .copy(
isShowingContent = oldStatus.isShowingContent, isShowingContent = oldStatus!!.isShowingContent,
isExpanded = oldStatus.isExpanded, isExpanded = oldStatus.isExpanded,
isCollapsed = oldStatus.isCollapsed, isCollapsed = oldStatus.isCollapsed,
) )
} }
statusData.removeAt(overlappedFrom) statusData.removeAll { status ->
when (status) {
is StatusViewData.Placeholder -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(firstId)
is StatusViewData.Concrete -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(firstId)
}
} }
} else { } else {
statusData.add(overlappedFrom, StatusViewData.Placeholder(statuses.last().id.dec(), isLoading = false)) data[data.size - 1] = StatusViewData.Placeholder(statuses.last().id, isLoading = false)
} }
} }
@ -195,7 +200,9 @@ class NetworkTimelineViewModel @Inject constructor(
currentSource?.invalidate() currentSource?.invalidate()
} catch (e: Exception) { } catch (e: Exception) {
loadMoreFailed(placeholderId, e) ifExpected(e) {
loadMoreFailed(placeholderId, e)
}
} }
} }
} }
@ -239,27 +246,27 @@ class NetworkTimelineViewModel @Inject constructor(
val activeAccount = accountManager.activeAccount!! val activeAccount = accountManager.activeAccount!!
if (isFirstOfStreaming) { if (isFirstOfStreaming) {
val placeholderId = status.id.dec() statusData.add(0, StatusViewData.Placeholder(status.id, isLoading = false))
statusData.add(0, StatusViewData.Placeholder(placeholderId, isLoading = false))
isFirstOfStreaming = false isFirstOfStreaming = false
} else {
statusData.add(0, status.toViewData(
isShowingContent = activeAccount.alwaysShowSensitiveMedia,
isExpanded = activeAccount.alwaysOpenSpoiler,
isCollapsed = true,
))
} }
statusData.add(0, status.toViewData(
isShowingContent = activeAccount.alwaysShowSensitiveMedia,
isExpanded = activeAccount.alwaysOpenSpoiler,
isCollapsed = true,
))
currentSource?.invalidate() currentSource?.invalidate()
} }
} }
override fun fullReload() { override fun fullReload() {
nextKey = statusData.firstOrNull { it is StatusViewData.Concrete }?.asStatusOrNull()?.id?.inc() nextKey = statusData.firstOrNull { it is StatusViewData.Concrete }?.asStatusOrNull()?.id
statusData.clear() statusData.clear()
currentSource?.invalidate() currentSource?.invalidate()
} }
@Throws(IOException::class, HttpException::class)
suspend fun fetchStatusesForKind( suspend fun fetchStatusesForKind(
fromId: String?, fromId: String?,
uptoId: String?, uptoId: String?,

View File

@ -20,7 +20,21 @@ import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
import com.keylesspalace.tusky.appstore.* import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.BookmarkEvent
import com.keylesspalace.tusky.appstore.DomainMuteEvent
import com.keylesspalace.tusky.appstore.Event
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.FavoriteEvent
import com.keylesspalace.tusky.appstore.MuteConversationEvent
import com.keylesspalace.tusky.appstore.MuteEvent
import com.keylesspalace.tusky.appstore.PinEvent
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.appstore.ReblogEvent
import com.keylesspalace.tusky.appstore.StatusDeletedEvent
import com.keylesspalace.tusky.appstore.StreamUpdateEvent
import com.keylesspalace.tusky.appstore.UnfollowEvent
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
@ -38,8 +52,6 @@ import kotlinx.coroutines.rx3.await
import net.accelf.yuito.streaming.StreamType import net.accelf.yuito.streaming.StreamType
import net.accelf.yuito.streaming.StreamingManager import net.accelf.yuito.streaming.StreamingManager
import net.accelf.yuito.streaming.Subscription import net.accelf.yuito.streaming.Subscription
import retrofit2.HttpException
import java.io.IOException
abstract class TimelineViewModel( abstract class TimelineViewModel(
private val timelineCases: TimelineCases, private val timelineCases: TimelineCases,
@ -344,19 +356,6 @@ abstract class TimelineViewModel(
} }
} }
private fun isExpectedRequestException(t: Exception) = t is IOException || t is HttpException
private inline fun ifExpected(
t: Exception,
cb: () -> Unit,
) {
if (isExpectedRequestException(t)) {
cb()
} else {
throw t
}
}
companion object { companion object {
private const val TAG = "TimelineVM" private const val TAG = "TimelineVM"
internal const val LOAD_AT_ONCE = 30 internal const val LOAD_AT_ONCE = 30

View File

@ -29,10 +29,9 @@ import java.io.File;
/** /**
* DB version & declare DAO * DB version & declare DAO
*/ */
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class TimelineAccountEntity.class, ConversationEntity.class
}, version = 30) }, version = 31)
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao(); public abstract AccountDao accountDao();
@ -474,4 +473,14 @@ public abstract class AppDatabase extends RoomDatabase {
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxPollDuration` INTEGER"); database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxPollDuration` INTEGER");
} }
}; };
public static final Migration MIGRATION_30_31 = new Migration(30, 31) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
// no actual scheme change, but placeholder ids are now used differently so the cache needs to be cleared to avoid bugs
database.execSQL("DELETE FROM `TimelineAccountEntity`");
database.execSQL("DELETE FROM `TimelineStatusEntity`");
}
};
} }

View File

@ -186,6 +186,15 @@ AND timelineUserId = :accountId
@Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1")
abstract suspend fun getTopPlaceholderId(accountId: Long): String? abstract suspend fun getTopPlaceholderId(accountId: Long): String?
/**
* Returns the id directly above [serverId], or null if [serverId] is the id of the top status
*/
@Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (LENGTH(:serverId) < LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId < serverId)) ORDER BY LENGTH(serverId) ASC, serverId ASC LIMIT 1")
abstract suspend fun getIdAbove(accountId: Long, serverId: String): String?
/**
* Returns the id of the next placeholder after [serverId]
*/
@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") @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? abstract suspend fun getNextPlaceholderIdAfter(accountId: Long, serverId: String): String?
} }

View File

@ -22,9 +22,7 @@ import com.keylesspalace.tusky.EditProfileActivity
import com.keylesspalace.tusky.FiltersActivity import com.keylesspalace.tusky.FiltersActivity
import com.keylesspalace.tusky.LicenseActivity import com.keylesspalace.tusky.LicenseActivity
import com.keylesspalace.tusky.ListsActivity import com.keylesspalace.tusky.ListsActivity
import com.keylesspalace.tusky.LoginActivity
import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.SplashActivity
import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.TabPreferenceActivity
import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.ViewMediaActivity
@ -34,9 +32,11 @@ import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.drafts.DraftsActivity import com.keylesspalace.tusky.components.drafts.DraftsActivity
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.login.LoginWebViewActivity
import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.report.ReportActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
import com.keylesspalace.tusky.components.search.SearchActivity import com.keylesspalace.tusky.components.search.SearchActivity
import dagger.Module import dagger.Module
import dagger.android.ContributesAndroidInjector import dagger.android.ContributesAndroidInjector
@ -86,7 +86,7 @@ abstract class ActivitiesModule {
abstract fun contributesLoginActivity(): LoginActivity abstract fun contributesLoginActivity(): LoginActivity
@ContributesAndroidInjector @ContributesAndroidInjector
abstract fun contributesSplashActivity(): SplashActivity abstract fun contributesLoginWebViewActivity(): LoginWebViewActivity
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) @ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesPreferencesActivity(): PreferencesActivity abstract fun contributesPreferencesActivity(): PreferencesActivity
@ -110,7 +110,7 @@ abstract class ActivitiesModule {
abstract fun contributesInstanceListActivity(): InstanceListActivity abstract fun contributesInstanceListActivity(): InstanceListActivity
@ContributesAndroidInjector @ContributesAndroidInjector
abstract fun contributesScheduledTootActivity(): ScheduledTootActivity abstract fun contributesScheduledStatusActivity(): ScheduledStatusActivity
@ContributesAndroidInjector @ContributesAndroidInjector
abstract fun contributesAnnouncementsActivity(): AnnouncementsActivity abstract fun contributesAnnouncementsActivity(): AnnouncementsActivity

View File

@ -68,7 +68,7 @@ class AppModule {
AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25, AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25,
AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")), AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")),
AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29, AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29,
AppDatabase.MIGRATION_29_30 AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31
) )
.build() .build()
} }

View File

@ -15,12 +15,12 @@
package com.keylesspalace.tusky.di package com.keylesspalace.tusky.di
import com.keylesspalace.tusky.service.SendTootService import com.keylesspalace.tusky.service.SendStatusService
import dagger.Module import dagger.Module
import dagger.android.ContributesAndroidInjector import dagger.android.ContributesAndroidInjector
@Module @Module
abstract class ServicesModule { abstract class ServicesModule {
@ContributesAndroidInjector @ContributesAndroidInjector
abstract fun contributesSendTootService(): SendTootService abstract fun contributesSendStatusService(): SendStatusService
} }

View File

@ -10,7 +10,7 @@ import com.keylesspalace.tusky.components.compose.ComposeViewModel
import com.keylesspalace.tusky.components.conversation.ConversationsViewModel import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
import com.keylesspalace.tusky.components.drafts.DraftsViewModel import com.keylesspalace.tusky.components.drafts.DraftsViewModel
import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.report.ReportViewModel
import com.keylesspalace.tusky.components.scheduled.ScheduledTootViewModel import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel
import com.keylesspalace.tusky.components.search.SearchViewModel import com.keylesspalace.tusky.components.search.SearchViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
@ -86,8 +86,8 @@ abstract class ViewModelModule {
@Binds @Binds
@IntoMap @IntoMap
@ViewModelKey(ScheduledTootViewModel::class) @ViewModelKey(ScheduledStatusViewModel::class)
internal abstract fun scheduledTootViewModel(viewModel: ScheduledTootViewModel): ViewModel internal abstract fun scheduledStatusViewModel(viewModel: ScheduledStatusViewModel): ViewModel
@Binds @Binds
@IntoMap @IntoMap

View File

@ -17,7 +17,7 @@ package com.keylesspalace.tusky.entity
import android.text.Spanned import android.text.Spanned
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import java.util.* import java.util.Date
data class Account( data class Account(
val id: String, val id: String,
@ -53,37 +53,57 @@ data class Account(
val intentionallyUseDisplayName: String val intentionallyUseDisplayName: String
get() = displayName.orEmpty() get() = displayName.orEmpty()
override fun hashCode(): Int {
return id.hashCode()
}
override fun equals(other: Any?): Boolean {
if (other !is Account) {
return false
}
return other.id == this.id
}
fun deepEquals(other: Account): Boolean {
return id == other.id &&
localUsername == other.localUsername &&
displayName == other.displayName &&
note == other.note &&
url == other.url &&
avatar == other.avatar &&
header == other.header &&
locked == other.locked &&
followersCount == other.followersCount &&
followingCount == other.followingCount &&
statusesCount == other.statusesCount &&
source == other.source &&
bot == other.bot &&
emojis == other.emojis &&
fields == other.fields &&
moved == other.moved
}
fun isRemote(): Boolean = this.username != this.localUsername fun isRemote(): Boolean = this.username != this.localUsername
/**
* overriding equals & hashcode because Spanned does not always compare correctly otherwise
*/
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Account
if (id != other.id) return false
if (localUsername != other.localUsername) return false
if (username != other.username) return false
if (displayName != other.displayName) return false
if (note.toString() != other.note.toString()) return false
if (url != other.url) return false
if (avatar != other.avatar) return false
if (header != other.header) return false
if (locked != other.locked) return false
if (followersCount != other.followersCount) return false
if (followingCount != other.followingCount) return false
if (statusesCount != other.statusesCount) return false
if (source != other.source) return false
if (bot != other.bot) return false
if (emojis != other.emojis) return false
if (fields != other.fields) return false
if (moved != other.moved) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + localUsername.hashCode()
result = 31 * result + username.hashCode()
result = 31 * result + (displayName?.hashCode() ?: 0)
result = 31 * result + note.toString().hashCode()
result = 31 * result + url.hashCode()
result = 31 * result + avatar.hashCode()
result = 31 * result + header.hashCode()
result = 31 * result + locked.hashCode()
result = 31 * result + followersCount
result = 31 * result + followingCount
result = 31 * result + statusesCount
result = 31 * result + (source?.hashCode() ?: 0)
result = 31 * result + bot.hashCode()
result = 31 * result + (emojis?.hashCode() ?: 0)
result = 31 * result + (fields?.hashCode() ?: 0)
result = 31 * result + (moved?.hashCode() ?: 0)
return result
}
} }
data class AccountSource( data class AccountSource(

View File

@ -19,7 +19,7 @@ import com.google.gson.annotations.SerializedName
data class Conversation( data class Conversation(
val id: String, val id: String,
val accounts: List<Account>, val accounts: List<TimelineAccount>,
@SerializedName("last_status") val lastStatus: Status?, // should never be null, but apparently its possible https://github.com/tuskyapp/Tusky/issues/1038 @SerializedName("last_status") val lastStatus: Status?, // should never be null, but apparently its possible https://github.com/tuskyapp/Tusky/issues/1038
val unread: Boolean val unread: Boolean
) )

View File

@ -24,7 +24,7 @@ import com.google.gson.annotations.JsonAdapter
data class Notification( data class Notification(
val type: Type, val type: Type,
val id: String, val id: String,
val account: Account, val account: TimelineAccount,
val status: Status? val status: Status?
) { ) {

View File

@ -16,7 +16,7 @@
package com.keylesspalace.tusky.entity package com.keylesspalace.tusky.entity
data class SearchResult( data class SearchResult(
val accounts: List<Account>, val accounts: List<TimelineAccount>,
val statuses: List<Status>, val statuses: List<Status>,
val hashtags: List<HashTag> val hashtags: List<HashTag>
) )

View File

@ -25,7 +25,7 @@ import java.util.Date
data class Status( data class Status(
val id: String, val id: String,
val url: String?, // not present if it's reblog val url: String?, // not present if it's reblog
val account: Account, val account: TimelineAccount,
@SerializedName("in_reply_to_id") var inReplyToId: String?, @SerializedName("in_reply_to_id") var inReplyToId: String?,
@SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?, @SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?,
val reblog: Status?, val reblog: Status?,
@ -158,6 +158,71 @@ data class Status(
return builder.toString() return builder.toString()
} }
/**
* overriding equals & hashcode because Spanned does not always compare correctly otherwise
*/
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Status
if (id != other.id) return false
if (url != other.url) return false
if (account != other.account) return false
if (inReplyToId != other.inReplyToId) return false
if (inReplyToAccountId != other.inReplyToAccountId) return false
if (reblog != other.reblog) return false
if (content.toString() != other.content.toString()) return false
if (createdAt != other.createdAt) return false
if (emojis != other.emojis) return false
if (reblogsCount != other.reblogsCount) return false
if (favouritesCount != other.favouritesCount) return false
if (reblogged != other.reblogged) return false
if (favourited != other.favourited) return false
if (bookmarked != other.bookmarked) return false
if (sensitive != other.sensitive) return false
if (spoilerText != other.spoilerText) return false
if (visibility != other.visibility) return false
if (attachments != other.attachments) return false
if (mentions != other.mentions) return false
if (tags != other.tags) return false
if (application != other.application) return false
if (pinned != other.pinned) return false
if (muted != other.muted) return false
if (poll != other.poll) return false
if (card != other.card) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + (url?.hashCode() ?: 0)
result = 31 * result + account.hashCode()
result = 31 * result + (inReplyToId?.hashCode() ?: 0)
result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0)
result = 31 * result + (reblog?.hashCode() ?: 0)
result = 31 * result + content.toString().hashCode()
result = 31 * result + createdAt.hashCode()
result = 31 * result + emojis.hashCode()
result = 31 * result + reblogsCount
result = 31 * result + favouritesCount
result = 31 * result + reblogged.hashCode()
result = 31 * result + favourited.hashCode()
result = 31 * result + bookmarked.hashCode()
result = 31 * result + sensitive.hashCode()
result = 31 * result + spoilerText.hashCode()
result = 31 * result + visibility.hashCode()
result = 31 * result + attachments.hashCode()
result = 31 * result + mentions.hashCode()
result = 31 * result + (tags?.hashCode() ?: 0)
result = 31 * result + (application?.hashCode() ?: 0)
result = 31 * result + (pinned?.hashCode() ?: 0)
result = 31 * result + (muted?.hashCode() ?: 0)
result = 31 * result + (poll?.hashCode() ?: 0)
result = 31 * result + (card?.hashCode() ?: 0)
return result
}
data class Mention( data class Mention(
val id: String, val id: String,
val url: String, val url: String,

View File

@ -0,0 +1,40 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.entity
import com.google.gson.annotations.SerializedName
/**
* Same as [Account], but only with the attributes required in timelines.
* Prefer this class over [Account] because it uses way less memory & deserializes faster from json.
*/
data class TimelineAccount(
val id: String,
@SerializedName("username") val localUsername: String,
@SerializedName("acct") val username: String,
@SerializedName("display_name") val displayName: String?, // should never be null per Api definition, but some servers break the contract
val url: String,
val avatar: String,
val bot: Boolean = false,
val emojis: List<Emoji>? = emptyList(), // nullable for backward compatibility
@SerializedName("name") val notestockUsername: String? = null,
) {
val name: String
get() = if (displayName.isNullOrEmpty()) {
localUsername
} else displayName
}

View File

@ -42,8 +42,8 @@ import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.databinding.FragmentAccountListBinding import com.keylesspalace.tusky.databinding.FragmentAccountListBinding
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
@ -255,7 +255,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
followRequestsAdapter.removeItem(position) followRequestsAdapter.removeItem(position)
} }
private fun getFetchCallByListType(fromId: String?): Single<Response<List<Account>>> { private fun getFetchCallByListType(fromId: String?): Single<Response<List<TimelineAccount>>> {
return when (type) { return when (type) {
Type.FOLLOWS -> { Type.FOLLOWS -> {
val accountId = requireId(type, id) val accountId = requireId(type, id)
@ -313,7 +313,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
) )
} }
private fun onFetchAccountsSuccess(accounts: List<Account>, linkHeader: String?) { private fun onFetchAccountsSuccess(accounts: List<TimelineAccount>, linkHeader: String?) {
adapter.setBottomLoading(false) adapter.setBottomLoading(false)
val links = HttpHeaderLink.parse(linkHeader) val links = HttpHeaderLink.parse(linkHeader)

View File

@ -243,7 +243,7 @@ public abstract class SFragment extends Fragment implements Injectable {
popup.setOnMenuItemClickListener(item -> { popup.setOnMenuItemClickListener(item -> {
switch (item.getItemId()) { switch (item.getItemId()) {
case R.id.status_share_content: { case R.id.post_share_content: {
Status statusToShare = status; Status statusToShare = status;
if (statusToShare.getReblog() != null) if (statusToShare.getReblog() != null)
statusToShare = statusToShare.getReblog(); statusToShare = statusToShare.getReblog();
@ -257,15 +257,15 @@ public abstract class SFragment extends Fragment implements Injectable {
sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare); sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare);
sendIntent.putExtra(Intent.EXTRA_SUBJECT, statusUrl); sendIntent.putExtra(Intent.EXTRA_SUBJECT, statusUrl);
sendIntent.setType("text/plain"); sendIntent.setType("text/plain");
startActivity(Intent.createChooser(sendIntent, getResources().getText(R.string.send_status_content_to))); startActivity(Intent.createChooser(sendIntent, getResources().getText(R.string.send_post_content_to)));
return true; return true;
} }
case R.id.status_share_link: { case R.id.post_share_link: {
Intent sendIntent = new Intent(); Intent sendIntent = new Intent();
sendIntent.setAction(Intent.ACTION_SEND); sendIntent.setAction(Intent.ACTION_SEND);
sendIntent.putExtra(Intent.EXTRA_TEXT, statusUrl); sendIntent.putExtra(Intent.EXTRA_TEXT, statusUrl);
sendIntent.setType("text/plain"); sendIntent.setType("text/plain");
startActivity(Intent.createChooser(sendIntent, getResources().getText(R.string.send_status_link_to))); startActivity(Intent.createChooser(sendIntent, getResources().getText(R.string.send_post_link_to)));
return true; return true;
} }
case R.id.status_copy_link: { case R.id.status_copy_link: {
@ -407,7 +407,7 @@ public abstract class SFragment extends Fragment implements Injectable {
protected void showConfirmDeleteDialog(final String id, final int position) { protected void showConfirmDeleteDialog(final String id, final int position) {
new AlertDialog.Builder(getActivity()) new AlertDialog.Builder(getActivity())
.setMessage(R.string.dialog_delete_toot_warning) .setMessage(R.string.dialog_delete_post_warning)
.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> {
timelineCases.delete(id) timelineCases.delete(id)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
@ -430,7 +430,7 @@ public abstract class SFragment extends Fragment implements Injectable {
return; return;
} }
new AlertDialog.Builder(getActivity()) new AlertDialog.Builder(getActivity())
.setMessage(R.string.dialog_redraft_toot_warning) .setMessage(R.string.dialog_redraft_post_warning)
.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> {
timelineCases.delete(id) timelineCases.delete(id)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
@ -442,7 +442,7 @@ public abstract class SFragment extends Fragment implements Injectable {
deletedStatus = status.toDeletedStatus(); deletedStatus = status.toDeletedStatus();
} }
ComposeOptions composeOptions = new ComposeOptions(); ComposeOptions composeOptions = new ComposeOptions();
composeOptions.setTootText(deletedStatus.getText()); composeOptions.setContent(deletedStatus.getText());
composeOptions.setInReplyToId(deletedStatus.getInReplyToId()); composeOptions.setInReplyToId(deletedStatus.getInReplyToId());
composeOptions.setVisibility(deletedStatus.getVisibility()); composeOptions.setVisibility(deletedStatus.getVisibility());
composeOptions.setContentWarning(deletedStatus.getSpoilerText()); composeOptions.setContentWarning(deletedStatus.getSpoilerText());

View File

@ -16,8 +16,10 @@
package com.keylesspalace.tusky.json package com.keylesspalace.tusky.json
import android.text.Spanned import android.text.Spanned
import android.text.SpannedString
import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat
import androidx.core.text.parseAsHtml import androidx.core.text.parseAsHtml
import androidx.core.text.toHtml
import com.google.gson.JsonDeserializationContext import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement import com.google.gson.JsonElement
@ -32,16 +34,29 @@ import java.lang.reflect.Type
class SpannedTypeAdapter : JsonDeserializer<Spanned>, JsonSerializer<Spanned?> { class SpannedTypeAdapter : JsonDeserializer<Spanned>, JsonSerializer<Spanned?> {
@Throws(JsonParseException::class) @Throws(JsonParseException::class)
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Spanned { override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Spanned {
/* Html.fromHtml returns trailing whitespace if the html ends in a </p> tag, which return json.asString
* all status contents do, so it should be trimmed. */ /* Mastodon uses 'white-space: pre-wrap;' so spaces are displayed as returned by the Api.
return Jsoup.parse(json.asString ?: "") * We can't use CSS so we replace spaces with non-breaking-spaces to emulate the behavior.
.apply { */
select(".quote-inline").forEach { it.remove() } ?.replace("<br> ", "<br>&nbsp;")
} ?.replace("<br /> ", "<br />&nbsp;")
.html().parseAsHtml().trimTrailingWhitespace() ?.replace("<br/> ", "<br/>&nbsp;")
?.replace(" ", "&nbsp;&nbsp;")
?.let { html ->
Jsoup.parse(html)
.apply {
select(".quote-inline").forEach { it.remove() }
}
.html()
}
?.parseAsHtml()
/* Html.fromHtml returns trailing whitespace if the html ends in a </p> tag, which
* most status contents do, so it should be trimmed. */
?.trimTrailingWhitespace()
?: SpannedString("")
} }
override fun serialize(src: Spanned?, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { override fun serialize(src: Spanned?, typeOfSrc: Type, context: JsonSerializationContext): JsonElement {
return JsonPrimitive(HtmlCompat.toHtml(src!!, HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL)) return JsonPrimitive(src!!.toHtml(HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL))
} }
} }

View File

@ -37,6 +37,7 @@ import com.keylesspalace.tusky.entity.ScheduledStatus
import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.StatusContext import com.keylesspalace.tusky.entity.StatusContext
import com.keylesspalace.tusky.entity.TimelineAccount
import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import okhttp3.MultipartBody import okhttp3.MultipartBody
@ -178,13 +179,13 @@ interface MastodonApi {
fun statusRebloggedBy( fun statusRebloggedBy(
@Path("id") statusId: String, @Path("id") statusId: String,
@Query("max_id") maxId: String? @Query("max_id") maxId: String?
): Single<Response<List<Account>>> ): Single<Response<List<TimelineAccount>>>
@GET("api/v1/statuses/{id}/favourited_by") @GET("api/v1/statuses/{id}/favourited_by")
fun statusFavouritedBy( fun statusFavouritedBy(
@Path("id") statusId: String, @Path("id") statusId: String,
@Query("max_id") maxId: String? @Query("max_id") maxId: String?
): Single<Response<List<Account>>> ): Single<Response<List<TimelineAccount>>>
@DELETE("api/v1/statuses/{id}") @DELETE("api/v1/statuses/{id}")
fun deleteStatus( fun deleteStatus(
@ -286,7 +287,7 @@ interface MastodonApi {
@Query("resolve") resolve: Boolean? = null, @Query("resolve") resolve: Boolean? = null,
@Query("limit") limit: Int? = null, @Query("limit") limit: Int? = null,
@Query("following") following: Boolean? = null @Query("following") following: Boolean? = null
): Single<List<Account>> ): Single<List<TimelineAccount>>
@GET("api/v1/accounts/{id}") @GET("api/v1/accounts/{id}")
fun account( fun account(
@ -317,13 +318,13 @@ interface MastodonApi {
fun accountFollowers( fun accountFollowers(
@Path("id") accountId: String, @Path("id") accountId: String,
@Query("max_id") maxId: String? @Query("max_id") maxId: String?
): Single<Response<List<Account>>> ): Single<Response<List<TimelineAccount>>>
@GET("api/v1/accounts/{id}/following") @GET("api/v1/accounts/{id}/following")
fun accountFollowing( fun accountFollowing(
@Path("id") accountId: String, @Path("id") accountId: String,
@Query("max_id") maxId: String? @Query("max_id") maxId: String?
): Single<Response<List<Account>>> ): Single<Response<List<TimelineAccount>>>
@FormUrlEncoded @FormUrlEncoded
@POST("api/v1/accounts/{id}/follow") @POST("api/v1/accounts/{id}/follow")
@ -384,12 +385,12 @@ interface MastodonApi {
@GET("api/v1/blocks") @GET("api/v1/blocks")
fun blocks( fun blocks(
@Query("max_id") maxId: String? @Query("max_id") maxId: String?
): Single<Response<List<Account>>> ): Single<Response<List<TimelineAccount>>>
@GET("api/v1/mutes") @GET("api/v1/mutes")
fun mutes( fun mutes(
@Query("max_id") maxId: String? @Query("max_id") maxId: String?
): Single<Response<List<Account>>> ): Single<Response<List<TimelineAccount>>>
@GET("api/v1/domain_blocks") @GET("api/v1/domain_blocks")
fun domainBlocks( fun domainBlocks(
@ -426,7 +427,7 @@ interface MastodonApi {
@GET("api/v1/follow_requests") @GET("api/v1/follow_requests")
fun followRequests( fun followRequests(
@Query("max_id") maxId: String? @Query("max_id") maxId: String?
): Single<Response<List<Account>>> ): Single<Response<List<TimelineAccount>>>
@POST("api/v1/follow_requests/{id}/authorize") @POST("api/v1/follow_requests/{id}/authorize")
fun authorizeFollowRequest( fun authorizeFollowRequest(
@ -440,24 +441,24 @@ interface MastodonApi {
@FormUrlEncoded @FormUrlEncoded
@POST("api/v1/apps") @POST("api/v1/apps")
fun authenticateApp( suspend fun authenticateApp(
@Header(DOMAIN_HEADER) domain: String, @Header(DOMAIN_HEADER) domain: String,
@Field("client_name") clientName: String, @Field("client_name") clientName: String,
@Field("redirect_uris") redirectUris: String, @Field("redirect_uris") redirectUris: String,
@Field("scopes") scopes: String, @Field("scopes") scopes: String,
@Field("website") website: String @Field("website") website: String
): Call<AppCredentials> ): AppCredentials
@FormUrlEncoded @FormUrlEncoded
@POST("oauth/token") @POST("oauth/token")
fun fetchOAuthToken( suspend fun fetchOAuthToken(
@Header(DOMAIN_HEADER) domain: String, @Header(DOMAIN_HEADER) domain: String,
@Field("client_id") clientId: String, @Field("client_id") clientId: String,
@Field("client_secret") clientSecret: String, @Field("client_secret") clientSecret: String,
@Field("redirect_uri") redirectUri: String, @Field("redirect_uri") redirectUri: String,
@Field("code") code: String, @Field("code") code: String,
@Field("grant_type") grantType: String @Field("grant_type") grantType: String
): Call<AccessToken> ): AccessToken
@FormUrlEncoded @FormUrlEncoded
@POST("api/v1/lists") @POST("api/v1/lists")
@ -481,7 +482,7 @@ interface MastodonApi {
fun getAccountsInList( fun getAccountsInList(
@Path("listId") listId: String, @Path("listId") listId: String,
@Query("limit") limit: Int @Query("limit") limit: Int
): Single<List<Account>> ): Single<List<TimelineAccount>>
@FormUrlEncoded @FormUrlEncoded
// @DELETE doesn't support fields // @DELETE doesn't support fields

View File

@ -18,19 +18,19 @@ package com.keylesspalace.tusky.receiver
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Color
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions
import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.service.SendTootService import com.keylesspalace.tusky.service.SendStatusService
import com.keylesspalace.tusky.service.TootToSend import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.randomAlphanumericString import com.keylesspalace.tusky.util.randomAlphanumericString
import dagger.android.AndroidInjection import dagger.android.AndroidInjection
import javax.inject.Inject import javax.inject.Inject
@ -45,22 +45,19 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context) AndroidInjection.inject(this, context)
val notificationId = intent.getIntExtra(NotificationHelper.KEY_NOTIFICATION_ID, -1)
val senderId = intent.getLongExtra(NotificationHelper.KEY_SENDER_ACCOUNT_ID, -1)
val senderIdentifier = intent.getStringExtra(NotificationHelper.KEY_SENDER_ACCOUNT_IDENTIFIER)
val senderFullName = intent.getStringExtra(NotificationHelper.KEY_SENDER_ACCOUNT_FULL_NAME)
val citedStatusId = intent.getStringExtra(NotificationHelper.KEY_CITED_STATUS_ID)
val visibility = intent.getSerializableExtra(NotificationHelper.KEY_VISIBILITY) as Status.Visibility
val spoiler = intent.getStringExtra(NotificationHelper.KEY_SPOILER) ?: ""
val mentions = intent.getStringArrayExtra(NotificationHelper.KEY_MENTIONS) ?: emptyArray()
val citedText = intent.getStringExtra(NotificationHelper.KEY_CITED_TEXT)
val localAuthorId = intent.getStringExtra(NotificationHelper.KEY_CITED_AUTHOR_LOCAL)
val account = accountManager.getAccountById(senderId)
val notificationManager = NotificationManagerCompat.from(context)
if (intent.action == NotificationHelper.REPLY_ACTION) { if (intent.action == NotificationHelper.REPLY_ACTION) {
val notificationId = intent.getIntExtra(NotificationHelper.KEY_NOTIFICATION_ID, -1)
val senderId = intent.getLongExtra(NotificationHelper.KEY_SENDER_ACCOUNT_ID, -1)
val senderIdentifier = intent.getStringExtra(NotificationHelper.KEY_SENDER_ACCOUNT_IDENTIFIER)
val senderFullName = intent.getStringExtra(NotificationHelper.KEY_SENDER_ACCOUNT_FULL_NAME)
val citedStatusId = intent.getStringExtra(NotificationHelper.KEY_CITED_STATUS_ID)
val visibility = intent.getSerializableExtra(NotificationHelper.KEY_VISIBILITY) as Status.Visibility
val spoiler = intent.getStringExtra(NotificationHelper.KEY_SPOILER) ?: ""
val mentions = intent.getStringArrayExtra(NotificationHelper.KEY_MENTIONS) ?: emptyArray()
val account = accountManager.getAccountById(senderId)
val notificationManager = NotificationManagerCompat.from(context)
val message = getReplyMessage(intent) val message = getReplyMessage(intent)
@ -85,9 +82,9 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
} else { } else {
val text = mentions.joinToString(" ", postfix = " ") { "@$it" } + message.toString() val text = mentions.joinToString(" ", postfix = " ") { "@$it" } + message.toString()
val sendIntent = SendTootService.sendTootIntent( val sendIntent = SendStatusService.sendStatusIntent(
context, context,
TootToSend( StatusToSend(
text = text, text = text,
warningText = spoiler, warningText = spoiler,
visibility = visibility.serverString(), visibility = visibility.serverString(),
@ -110,14 +107,20 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
context.startService(sendIntent) context.startService(sendIntent)
val color = if (BuildConfig.FLAVOR == "green") {
Color.parseColor("#19A341")
} else {
ContextCompat.getColor(context, R.color.tusky_blue)
}
val builder = NotificationCompat.Builder(context, NotificationHelper.CHANNEL_MENTION + senderIdentifier) val builder = NotificationCompat.Builder(context, NotificationHelper.CHANNEL_MENTION + senderIdentifier)
.setSmallIcon(R.drawable.ic_notify) .setSmallIcon(R.drawable.ic_notify)
.setColor(ContextCompat.getColor(context, (R.color.tusky_blue))) .setColor(color)
.setGroup(senderFullName) .setGroup(senderFullName)
.setDefaults(0) // So it doesn't ring twice, notify only in Target callback .setDefaults(0) // So it doesn't ring twice, notify only in Target callback
builder.setContentTitle(context.getString(R.string.status_sent)) builder.setContentTitle(context.getString(R.string.post_sent))
builder.setContentText(context.getString(R.string.status_sent_long)) builder.setContentText(context.getString(R.string.post_sent_long))
builder.setSubText(senderFullName) builder.setSubText(senderFullName)
builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
@ -126,29 +129,6 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
notificationManager.notify(notificationId, builder.build()) notificationManager.notify(notificationId, builder.build())
} }
} else if (intent.action == NotificationHelper.COMPOSE_ACTION) {
context.sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS))
notificationManager.cancel(notificationId)
accountManager.setActiveAccount(senderId)
val composeIntent = ComposeActivity.startIntent(
context,
ComposeOptions(
inReplyToId = citedStatusId,
replyVisibility = visibility,
contentWarning = spoiler,
mentionedUsernames = mentions.toSet(),
replyingStatusAuthor = localAuthorId,
replyingStatusContent = citedText
)
)
composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(composeIntent)
} }
} }

View File

@ -19,8 +19,8 @@ import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent
import com.keylesspalace.tusky.appstore.StatusScheduledEvent import com.keylesspalace.tusky.appstore.StatusScheduledEvent
import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.NewStatus import com.keylesspalace.tusky.entity.NewStatus
@ -30,18 +30,17 @@ import dagger.android.AndroidInjection
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import retrofit2.Call import retrofit2.Call
import retrofit2.Callback import retrofit2.Callback
import retrofit2.Response import retrofit2.Response
import java.util.Timer
import java.util.TimerTask
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
class SendTootService : Service(), Injectable { class SendStatusService : Service(), Injectable {
@Inject @Inject
lateinit var mastodonApi: MastodonApi lateinit var mastodonApi: MastodonApi
@ -50,18 +49,14 @@ class SendTootService : Service(), Injectable {
@Inject @Inject
lateinit var eventHub: EventHub lateinit var eventHub: EventHub
@Inject @Inject
lateinit var database: AppDatabase
@Inject
lateinit var draftHelper: DraftHelper lateinit var draftHelper: DraftHelper
private val supervisorJob = SupervisorJob() private val supervisorJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + supervisorJob) private val serviceScope = CoroutineScope(Dispatchers.Main + supervisorJob)
private val tootsToSend = ConcurrentHashMap<Int, TootToSend>() private val statusesToSend = ConcurrentHashMap<Int, StatusToSend>()
private val sendCalls = ConcurrentHashMap<Int, Call<Status>>() private val sendCalls = ConcurrentHashMap<Int, Call<Status>>()
private val timer = Timer()
private val notificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } private val notificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager }
override fun onCreate() { override fun onCreate() {
@ -75,38 +70,38 @@ class SendTootService : Service(), Injectable {
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
if (intent.hasExtra(KEY_TOOT)) { if (intent.hasExtra(KEY_STATUS)) {
val tootToSend = intent.getParcelableExtra<TootToSend>(KEY_TOOT) val statusToSend = intent.getParcelableExtra<StatusToSend>(KEY_STATUS)
?: throw IllegalStateException("SendTootService started without $KEY_TOOT extra") ?: throw IllegalStateException("SendStatusService started without $KEY_STATUS extra")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(CHANNEL_ID, getString(R.string.send_toot_notification_channel_name), NotificationManager.IMPORTANCE_LOW) val channel = NotificationChannel(CHANNEL_ID, getString(R.string.send_post_notification_channel_name), NotificationManager.IMPORTANCE_LOW)
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }
var notificationText = tootToSend.warningText var notificationText = statusToSend.warningText
if (notificationText.isBlank()) { if (notificationText.isBlank()) {
notificationText = tootToSend.text notificationText = statusToSend.text
} }
val builder = NotificationCompat.Builder(this, CHANNEL_ID) val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify) .setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_toot_notification_title)) .setContentTitle(getString(R.string.send_post_notification_title))
.setContentText(notificationText) .setContentText(notificationText)
.setProgress(1, 0, true) .setProgress(1, 0, true)
.setOngoing(true) .setOngoing(true)
.setColor(ContextCompat.getColor(this, R.color.tusky_blue)) .setColor(ContextCompat.getColor(this, R.color.notification_color))
.addAction(0, getString(android.R.string.cancel), cancelSendingIntent(sendingNotificationId)) .addAction(0, getString(android.R.string.cancel), cancelSendingIntent(sendingNotificationId))
if (tootsToSend.size == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (statusesToSend.size == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH) ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
startForeground(sendingNotificationId, builder.build()) startForeground(sendingNotificationId, builder.build())
} else { } else {
notificationManager.notify(sendingNotificationId, builder.build()) notificationManager.notify(sendingNotificationId, builder.build())
} }
tootsToSend[sendingNotificationId] = tootToSend statusesToSend[sendingNotificationId] = statusToSend
sendToot(sendingNotificationId--) sendStatus(sendingNotificationId--)
} else { } else {
if (intent.hasExtra(KEY_CANCEL)) { if (intent.hasExtra(KEY_CANCEL)) {
@ -117,96 +112,96 @@ class SendTootService : Service(), Injectable {
return START_NOT_STICKY return START_NOT_STICKY
} }
private fun sendToot(tootId: Int) { private fun sendStatus(statusId: Int) {
// when tootToSend == null, sending has been canceled // when statusToSend == null, sending has been canceled
val tootToSend = tootsToSend[tootId] ?: return val statusToSend = statusesToSend[statusId] ?: return
// when account == null, user has logged out, cancel sending // when account == null, user has logged out, cancel sending
val account = accountManager.getAccountById(tootToSend.accountId) val account = accountManager.getAccountById(statusToSend.accountId)
if (account == null) { if (account == null) {
tootsToSend.remove(tootId) statusesToSend.remove(statusId)
notificationManager.cancel(tootId) notificationManager.cancel(statusId)
stopSelfWhenDone() stopSelfWhenDone()
return return
} }
tootToSend.retries++ statusToSend.retries++
val newStatus = NewStatus( val newStatus = NewStatus(
tootToSend.text, statusToSend.text,
tootToSend.warningText, statusToSend.warningText,
tootToSend.inReplyToId, statusToSend.inReplyToId,
tootToSend.visibility, statusToSend.visibility,
tootToSend.sensitive, statusToSend.sensitive,
tootToSend.mediaIds, statusToSend.mediaIds,
tootToSend.scheduledAt, statusToSend.scheduledAt,
tootToSend.poll, statusToSend.poll,
tootToSend.quoteId, statusToSend.quoteId,
) )
val sendCall = mastodonApi.createStatus( val sendCall = mastodonApi.createStatus(
"Bearer " + account.accessToken, "Bearer " + account.accessToken,
account.domain, account.domain,
tootToSend.idempotencyKey, statusToSend.idempotencyKey,
newStatus newStatus
) )
sendCalls[tootId] = sendCall sendCalls[statusId] = sendCall
val callback = object : Callback<Status> { val callback = object : Callback<Status> {
override fun onResponse(call: Call<Status>, response: Response<Status>) { override fun onResponse(call: Call<Status>, response: Response<Status>) {
serviceScope.launch {
val scheduled = !tootToSend.scheduledAt.isNullOrEmpty() val scheduled = !statusToSend.scheduledAt.isNullOrEmpty()
tootsToSend.remove(tootId) statusesToSend.remove(statusId)
if (response.isSuccessful) { if (response.isSuccessful) {
// If the status was loaded from a draft, delete the draft and associated media files. // If the status was loaded from a draft, delete the draft and associated media files.
if (tootToSend.draftId != 0) { if (statusToSend.draftId != 0) {
serviceScope.launch { draftHelper.deleteDraftAndAttachments(statusToSend.draftId)
draftHelper.deleteDraftAndAttachments(tootToSend.draftId)
} }
}
if (scheduled) { if (scheduled) {
response.body()?.let(::StatusScheduledEvent)?.let(eventHub::dispatch) response.body()?.let(::StatusScheduledEvent)?.let(eventHub::dispatch)
} else {
response.body()?.let(::StatusComposedEvent)?.let(eventHub::dispatch)
}
notificationManager.cancel(statusId)
} else { } else {
response.body()?.let(::StatusComposedEvent)?.let(eventHub::dispatch) // the server refused to accept the status, save status & show error message
saveStatusToDrafts(statusToSend)
val builder = NotificationCompat.Builder(this@SendStatusService, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_post_notification_error_title))
.setContentText(getString(R.string.send_post_notification_saved_content))
.setColor(
ContextCompat.getColor(
this@SendStatusService,
R.color.notification_color
)
)
notificationManager.cancel(statusId)
notificationManager.notify(errorNotificationId--, builder.build())
} }
stopSelfWhenDone()
notificationManager.cancel(tootId)
} else {
// the server refused to accept the toot, save toot & show error message
saveTootToDrafts(tootToSend)
val builder = NotificationCompat.Builder(this@SendTootService, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_toot_notification_error_title))
.setContentText(getString(R.string.send_toot_notification_saved_content))
.setColor(ContextCompat.getColor(this@SendTootService, R.color.tusky_blue))
notificationManager.cancel(tootId)
notificationManager.notify(errorNotificationId--, builder.build())
} }
stopSelfWhenDone()
} }
override fun onFailure(call: Call<Status>, t: Throwable) { override fun onFailure(call: Call<Status>, t: Throwable) {
var backoff = TimeUnit.SECONDS.toMillis(tootToSend.retries.toLong()) serviceScope.launch {
if (backoff > MAX_RETRY_INTERVAL) { var backoff = TimeUnit.SECONDS.toMillis(statusToSend.retries.toLong())
backoff = MAX_RETRY_INTERVAL if (backoff > MAX_RETRY_INTERVAL) {
} backoff = MAX_RETRY_INTERVAL
}
timer.schedule( delay(backoff)
object : TimerTask() { sendStatus(statusId)
override fun run() { }
sendToot(tootId)
}
},
backoff
)
} }
} }
@ -215,65 +210,52 @@ class SendTootService : Service(), Injectable {
private fun stopSelfWhenDone() { private fun stopSelfWhenDone() {
if (tootsToSend.isEmpty()) { if (statusesToSend.isEmpty()) {
ServiceCompat.stopForeground(this@SendTootService, ServiceCompat.STOP_FOREGROUND_REMOVE) ServiceCompat.stopForeground(this@SendStatusService, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
} }
} }
private fun cancelSending(tootId: Int) { private fun cancelSending(statusId: Int) = serviceScope.launch {
val tootToCancel = tootsToSend.remove(tootId) val statusToCancel = statusesToSend.remove(statusId)
if (tootToCancel != null) { if (statusToCancel != null) {
val sendCall = sendCalls.remove(tootId) val sendCall = sendCalls.remove(statusId)
sendCall?.cancel() sendCall?.cancel()
saveTootToDrafts(tootToCancel) saveStatusToDrafts(statusToCancel)
val builder = NotificationCompat.Builder(this@SendTootService, CHANNEL_ID) val builder = NotificationCompat.Builder(this@SendStatusService, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify) .setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_toot_notification_cancel_title)) .setContentTitle(getString(R.string.send_post_notification_cancel_title))
.setContentText(getString(R.string.send_toot_notification_saved_content)) .setContentText(getString(R.string.send_post_notification_saved_content))
.setColor(ContextCompat.getColor(this@SendTootService, R.color.tusky_blue)) .setColor(ContextCompat.getColor(this@SendStatusService, R.color.notification_color))
notificationManager.notify(tootId, builder.build()) notificationManager.notify(statusId, builder.build())
timer.schedule( delay(5000)
object : TimerTask() {
override fun run() {
notificationManager.cancel(tootId)
stopSelfWhenDone()
}
},
5000
)
} }
} }
private fun saveTootToDrafts(toot: TootToSend) { private suspend fun saveStatusToDrafts(status: StatusToSend) {
serviceScope.launch { draftHelper.saveDraft(
draftHelper.saveDraft( draftId = status.draftId,
draftId = toot.draftId, accountId = status.accountId,
accountId = toot.accountId, inReplyToId = status.inReplyToId,
inReplyToId = toot.inReplyToId, content = status.text,
content = toot.text, contentWarning = status.warningText,
contentWarning = toot.warningText, sensitive = status.sensitive,
sensitive = toot.sensitive, visibility = Status.Visibility.byString(status.visibility),
visibility = Status.Visibility.byString(toot.visibility), mediaUris = status.mediaUris,
mediaUris = toot.mediaUris, mediaDescriptions = status.mediaDescriptions,
mediaDescriptions = toot.mediaDescriptions, poll = status.poll,
poll = toot.poll, failedToSend = true
failedToSend = true )
)
}
} }
private fun cancelSendingIntent(tootId: Int): PendingIntent { private fun cancelSendingIntent(statusId: Int): PendingIntent {
val intent = Intent(this, SendStatusService::class.java)
val intent = Intent(this, SendTootService::class.java) intent.putExtra(KEY_CANCEL, statusId)
return PendingIntent.getService(this, statusId, intent, NotificationHelper.pendingIntentFlags(false))
intent.putExtra(KEY_CANCEL, tootId)
return PendingIntent.getService(this, tootId, intent, PendingIntent.FLAG_UPDATE_CURRENT)
} }
override fun onDestroy() { override fun onDestroy() {
@ -283,7 +265,7 @@ class SendTootService : Service(), Injectable {
companion object { companion object {
private const val KEY_TOOT = "toot" private const val KEY_STATUS = "status"
private const val KEY_CANCEL = "cancel_id" private const val KEY_CANCEL = "cancel_id"
private const val CHANNEL_ID = "send_toots" private const val CHANNEL_ID = "send_toots"
@ -293,21 +275,21 @@ class SendTootService : Service(), Injectable {
private var errorNotificationId = Int.MIN_VALUE // use even more negative ids to not clash with other notis private var errorNotificationId = Int.MIN_VALUE // use even more negative ids to not clash with other notis
@JvmStatic @JvmStatic
fun sendTootIntent( fun sendStatusIntent(
context: Context, context: Context,
tootToSend: TootToSend statusToSend: StatusToSend
): Intent { ): Intent {
val intent = Intent(context, SendTootService::class.java) val intent = Intent(context, SendStatusService::class.java)
intent.putExtra(KEY_TOOT, tootToSend) intent.putExtra(KEY_STATUS, statusToSend)
if (tootToSend.mediaUris.isNotEmpty()) { if (statusToSend.mediaUris.isNotEmpty()) {
// forward uri permissions // forward uri permissions
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val uriClip = ClipData( val uriClip = ClipData(
ClipDescription("Toot Media", arrayOf("image/*", "video/*")), ClipDescription("Status Media", arrayOf("image/*", "video/*")),
ClipData.Item(tootToSend.mediaUris[0]) ClipData.Item(statusToSend.mediaUris[0])
) )
tootToSend.mediaUris statusToSend.mediaUris
.drop(1) .drop(1)
.forEach { mediaUri -> .forEach { mediaUri ->
uriClip.addItem(ClipData.Item(mediaUri)) uriClip.addItem(ClipData.Item(mediaUri))
@ -322,7 +304,7 @@ class SendTootService : Service(), Injectable {
} }
@Parcelize @Parcelize
data class TootToSend( data class StatusToSend(
val text: String, val text: String,
val warningText: String, val warningText: String,
val visibility: String, val visibility: String,

View File

@ -20,8 +20,8 @@ import androidx.core.content.ContextCompat
import javax.inject.Inject import javax.inject.Inject
class ServiceClient @Inject constructor(private val context: Context) { class ServiceClient @Inject constructor(private val context: Context) {
fun sendToot(tootToSend: TootToSend) { fun sendToot(tootToSend: StatusToSend) {
val intent = SendTootService.sendTootIntent(context, tootToSend) val intent = SendStatusService.sendStatusIntent(context, tootToSend)
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
} }
} }

View File

@ -280,7 +280,7 @@ class EmojiCompatFont(
R.string.caption_blobmoji, R.string.caption_blobmoji,
R.drawable.ic_blobmoji, R.drawable.ic_blobmoji,
"https://tusky.app/hosted/emoji/BlobmojiCompat.ttf", "https://tusky.app/hosted/emoji/BlobmojiCompat.ttf",
"12.0.0" "14.0.1"
) )
val TWEMOJI = EmojiCompatFont( val TWEMOJI = EmojiCompatFont(
"Twemoji", "Twemoji",
@ -288,7 +288,7 @@ class EmojiCompatFont(
R.string.caption_twemoji, R.string.caption_twemoji,
R.drawable.ic_twemoji, R.drawable.ic_twemoji,
"https://tusky.app/hosted/emoji/TwemojiCompat.ttf", "https://tusky.app/hosted/emoji/TwemojiCompat.ttf",
"12.0.0" "14.0.0"
) )
val NOTOEMOJI = EmojiCompatFont( val NOTOEMOJI = EmojiCompatFont(
"NotoEmoji", "NotoEmoji",
@ -296,7 +296,7 @@ class EmojiCompatFont(
R.string.caption_notoemoji, R.string.caption_notoemoji,
R.drawable.ic_notoemoji, R.drawable.ic_notoemoji,
"https://tusky.app/hosted/emoji/NotoEmojiCompat.ttf", "https://tusky.app/hosted/emoji/NotoEmojiCompat.ttf",
"11.0.0" "14.0.0"
) )
/** /**

View File

@ -149,7 +149,7 @@ fun setClickableMentions(view: TextView, mentions: List<Mention>?, listener: Lin
for (mention in mentions) { for (mention in mentions) {
val customSpan = getCustomSpanForMentionUrl(mention.url, mention.id, listener) val customSpan = getCustomSpanForMentionUrl(mention.url, mention.id, listener)
end += 1 + mention.username.length // length of @ + username end += 1 + mention.localUsername.length // length of @ + username
flags = getSpanFlags(customSpan) flags = getSpanFlags(customSpan)
if (firstMention) { if (firstMention) {
firstMention = false firstMention = false
@ -160,7 +160,7 @@ fun setClickableMentions(view: TextView, mentions: List<Mention>?, listener: Lin
} }
append("@") append("@")
append(mention.username) append(mention.localUsername)
setSpan(customSpan, start, end, flags) setSpan(customSpan, start, end, flags)
append("\u200B") // same reasoning as in setClickableText append("\u200B") // same reasoning as in setClickableText
end += 1 // shift position to take the previous character into account end += 1 // shift position to take the previous character into account

View File

@ -270,12 +270,12 @@ class ListStatusAccessibilityDelegate(
private val collapseCwAction = AccessibilityActionCompat( private val collapseCwAction = AccessibilityActionCompat(
R.id.action_collapse_cw, R.id.action_collapse_cw,
context.getString(R.string.status_content_warning_show_less) context.getString(R.string.post_content_warning_show_less)
) )
private val expandCwAction = AccessibilityActionCompat( private val expandCwAction = AccessibilityActionCompat(
R.id.action_expand_cw, R.id.action_expand_cw,
context.getString(R.string.status_content_warning_show_more) context.getString(R.string.post_content_warning_show_more)
) )
private val replyAction = AccessibilityActionCompat( private val replyAction = AccessibilityActionCompat(

View File

@ -176,9 +176,9 @@ class StatusViewHelper(private val itemView: View) {
sensitiveMediaShow.visibility = View.GONE sensitiveMediaShow.visibility = View.GONE
} else { } else {
sensitiveMediaWarning.text = if (sensitive) { sensitiveMediaWarning.text = if (sensitive) {
context.getString(R.string.status_sensitive_media_title) context.getString(R.string.post_sensitive_media_title)
} else { } else {
context.getString(R.string.status_media_hidden_title) context.getString(R.string.post_media_hidden_title)
} }
sensitiveMediaWarning.visibility = if (showingContent) View.GONE else View.VISIBLE sensitiveMediaWarning.visibility = if (showingContent) View.GONE else View.VISIBLE
@ -225,7 +225,7 @@ class StatusViewHelper(private val itemView: View) {
val context = mediaLabel.context val context = mediaLabel.context
var labelText = getLabelTypeText(context, attachments[0].type) var labelText = getLabelTypeText(context, attachments[0].type)
if (sensitive) { if (sensitive) {
val sensitiveText = context.getString(R.string.status_sensitive_media_title) val sensitiveText = context.getString(R.string.post_sensitive_media_title)
labelText += String.format(" (%s)", sensitiveText) labelText += String.format(" (%s)", sensitiveText)
} }
mediaLabel.text = labelText mediaLabel.text = labelText
@ -239,10 +239,10 @@ class StatusViewHelper(private val itemView: View) {
private fun getLabelTypeText(context: Context, type: Attachment.Type): String { private fun getLabelTypeText(context: Context, type: Attachment.Type): String {
return when (type) { return when (type) {
Attachment.Type.IMAGE -> context.getString(R.string.status_media_images) Attachment.Type.IMAGE -> context.getString(R.string.post_media_images)
Attachment.Type.GIFV, Attachment.Type.VIDEO -> context.getString(R.string.status_media_video) Attachment.Type.GIFV, Attachment.Type.VIDEO -> context.getString(R.string.post_media_video)
Attachment.Type.AUDIO -> context.getString(R.string.status_media_audio) Attachment.Type.AUDIO -> context.getString(R.string.post_media_audio)
else -> context.getString(R.string.status_media_attachments) else -> context.getString(R.string.post_media_attachments)
} }
} }

View File

@ -16,51 +16,6 @@ fun randomAlphanumericString(count: Int): String {
return String(chars) return String(chars)
} }
// We sort statuses by ID. Something we need to invent some ID for placeholder.
/**
* "Increment" string so that during sorting it's bigger than [this]. Inverse operation to [dec].
*/
fun String.inc(): String {
val builder = this.toCharArray()
var i = builder.lastIndex
while (i >= 0) {
if (builder[i] < 'z') {
builder[i] = builder[i].inc()
return String(builder)
} else {
builder[i] = '0'
}
i--
}
return String(
CharArray(builder.size + 1) { index ->
if (index == 0) '0' else builder[index - 1]
}
)
}
/**
* "Decrement" string so that during sorting it's smaller than [this]. Inverse operation to [inc].
*/
fun String.dec(): String {
if (this.isEmpty()) return this
val builder = this.toCharArray()
var i = builder.lastIndex
while (i >= 0) {
if (builder[i] > '0') {
builder[i] = builder[i].dec()
return String(builder)
} else {
builder[i] = 'z'
}
i--
}
return String(builder.copyOfRange(1, builder.size))
}
/** /**
* A < B (strictly) by length and then by content. * A < B (strictly) by length and then by content.
* Examples: * Examples:
@ -78,6 +33,19 @@ fun String.isLessThan(other: String): Boolean {
} }
} }
/**
* A <= B (strictly) by length and then by content.
* Examples:
* "abc" <= "bcd"
* "ab" <= "abc"
* "cb" <= "abc"
* "ab" <= "ab"
* not: "abc" > "cb"
*/
fun String.isLessThanOrEqual(other: String): Boolean {
return this == other || isLessThan(other)
}
fun Spanned.trimTrailingWhitespace(): Spanned { fun Spanned.trimTrailingWhitespace(): Spanned {
var i = length var i = length
do { do {

View File

@ -19,6 +19,7 @@ import androidx.annotation.Nullable;
import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.TimelineAccount;
import java.util.Objects; import java.util.Objects;
@ -44,11 +45,11 @@ public abstract class NotificationViewData {
public static final class Concrete extends NotificationViewData { public static final class Concrete extends NotificationViewData {
private final Notification.Type type; private final Notification.Type type;
private final String id; private final String id;
private final Account account; private final TimelineAccount account;
@Nullable @Nullable
private final StatusViewData.Concrete statusViewData; private final StatusViewData.Concrete statusViewData;
public Concrete(Notification.Type type, String id, Account account, public Concrete(Notification.Type type, String id, TimelineAccount account,
@Nullable StatusViewData.Concrete statusViewData) { @Nullable StatusViewData.Concrete statusViewData) {
this.type = type; this.type = type;
this.id = id; this.id = id;
@ -64,7 +65,7 @@ public abstract class NotificationViewData {
return id; return id;
} }
public Account getAccount() { public TimelineAccount getAccount() {
return account; return account;
} }

View File

@ -22,12 +22,11 @@ import com.keylesspalace.tusky.entity.Status
/** /**
* Created by charlag on 11/07/2017. * Created by charlag on 11/07/2017.
* *
*
* Class to represent data required to display either a notification or a placeholder. * Class to represent data required to display either a notification or a placeholder.
* It is either a [StatusViewData.Concrete] or a [StatusViewData.Placeholder]. * It is either a [StatusViewData.Concrete] or a [StatusViewData.Placeholder].
*/ */
sealed class StatusViewData private constructor() { sealed class StatusViewData {
abstract val viewDataId: Long abstract val id: String
data class Concrete( data class Concrete(
val status: Status, val status: Status,
@ -49,8 +48,8 @@ sealed class StatusViewData private constructor() {
/** Whether the status meets the requirement to be collapse */ /** Whether the status meets the requirement to be collapse */
val isCollapsed: Boolean, val isCollapsed: Boolean,
) : StatusViewData() { ) : StatusViewData() {
override val viewDataId: Long override val id: String
get() = status.id.hashCode().toLong() get() = status.id
val content: Spanned val content: Spanned
val spoilerText: String val spoilerText: String
@ -116,9 +115,6 @@ sealed class StatusViewData private constructor() {
} }
} }
val id: String
get() = status.id
/** Helper for Java */ /** Helper for Java */
fun copyWithStatus(status: Status): Concrete { fun copyWithStatus(status: Status): Concrete {
return copy(status = status) return copy(status = status)
@ -140,10 +136,10 @@ sealed class StatusViewData private constructor() {
} }
} }
data class Placeholder(val id: String, val isLoading: Boolean) : StatusViewData() { data class Placeholder(
override val viewDataId: Long override val id: String,
get() = id.hashCode().toLong() val isLoading: Boolean
} ) : StatusViewData()
fun asStatusOrNull() = this as? Concrete fun asStatusOrNull() = this as? Concrete

View File

@ -17,7 +17,7 @@
package com.keylesspalace.tusky.viewmodel package com.keylesspalace.tusky.viewmodel
import android.util.Log import android.util.Log
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.Either.Left import com.keylesspalace.tusky.util.Either.Left
@ -28,7 +28,7 @@ import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.subjects.BehaviorSubject import io.reactivex.rxjava3.subjects.BehaviorSubject
import javax.inject.Inject import javax.inject.Inject
data class State(val accounts: Either<Throwable, List<Account>>, val searchResult: List<Account>?) data class State(val accounts: Either<Throwable, List<TimelineAccount>>, val searchResult: List<TimelineAccount>?)
class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) : RxAwareViewModel() { class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) : RxAwareViewModel() {
@ -49,7 +49,7 @@ class AccountsInListViewModel @Inject constructor(private val api: MastodonApi)
} }
} }
fun addAccountToList(listId: String, account: Account) { fun addAccountToList(listId: String, account: TimelineAccount) {
api.addCountToList(listId, listOf(account.id)) api.addCountToList(listId, listOf(account.id))
.subscribe( .subscribe(
{ {

View File

@ -63,7 +63,7 @@ class QuickTootViewModel @Inject constructor(
fun composeOptions(tootRightNow: Boolean): ComposeActivity.ComposeOptions { fun composeOptions(tootRightNow: Boolean): ComposeActivity.ComposeOptions {
return ComposeActivity.ComposeOptions( return ComposeActivity.ComposeOptions(
tootText = content.value, content = content.value,
mentionedUsernames = inReplyTo.value mentionedUsernames = inReplyTo.value
?.let { ?.let {
linkedSetOf(it.account.username, *(it.mentions.map { mention -> mention.username }.toTypedArray())) linkedSetOf(it.account.username, *(it.mentions.map { mention -> mention.username }.toTypedArray()))

View File

@ -10,10 +10,10 @@ import androidx.annotation.Px;
import com.google.android.material.button.MaterialButton; import com.google.android.material.button.MaterialButton;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.HashTag; import com.keylesspalace.tusky.entity.HashTag;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper;
@ -23,21 +23,21 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions;
import java.util.List; import java.util.List;
public class QuoteInlineHelper { public class QuoteInlineHelper {
private Status quoteStatus; private final Status quoteStatus;
private View quoteContainer; private final View quoteContainer;
private ImageView quoteAvatar; private final ImageView quoteAvatar;
private TextView quoteDisplayName; private final TextView quoteDisplayName;
private TextView quoteUsername; private final TextView quoteUsername;
private TextView quoteContentWarningDescription; private final TextView quoteContentWarningDescription;
private MaterialButton quoteContentWarningButton; private final MaterialButton quoteContentWarningButton;
private TextView quoteContent; private final TextView quoteContent;
private TextView quoteMedia; private final TextView quoteMedia;
private LinkListener listener; private final LinkListener listener;
@Px @Px
private int avatarRadius24dp; private final int avatarRadius24dp;
private StatusDisplayOptions statusDisplayOptions; private final StatusDisplayOptions statusDisplayOptions;
public QuoteInlineHelper(Status status, View container, LinkListener listener, public QuoteInlineHelper(Status status, View container, LinkListener listener,
@Px int avatarRadius24dp, StatusDisplayOptions statusDisplayOptions) { @Px int avatarRadius24dp, StatusDisplayOptions statusDisplayOptions) {
@ -62,7 +62,7 @@ public class QuoteInlineHelper {
private void setUsername(String name) { private void setUsername(String name) {
Context context = quoteUsername.getContext(); Context context = quoteUsername.getContext();
String format = context.getString(R.string.status_username_format); String format = context.getString(R.string.post_username_format);
String usernameText = String.format(format, name); String usernameText = String.format(format, name);
quoteUsername.setText(usernameText); quoteUsername.setText(usernameText);
} }
@ -97,10 +97,10 @@ public class QuoteInlineHelper {
private void setContentVisibility(boolean show) { private void setContentVisibility(boolean show) {
if (show) { if (show) {
quoteContent.setVisibility(View.VISIBLE); quoteContent.setVisibility(View.VISIBLE);
quoteContentWarningButton.setText(R.string.status_content_warning_show_less); quoteContentWarningButton.setText(R.string.post_content_warning_show_less);
} else { } else {
quoteContent.setVisibility(View.GONE); quoteContent.setVisibility(View.GONE);
quoteContentWarningButton.setText(R.string.status_content_warning_show_more); quoteContentWarningButton.setText(R.string.post_content_warning_show_more);
} }
} }
@ -120,7 +120,7 @@ public class QuoteInlineHelper {
} }
public void setupQuoteContainer() { public void setupQuoteContainer() {
Account account = quoteStatus.getAccount(); TimelineAccount account = quoteStatus.getAccount();
setDisplayName(account.getName(), account.getEmojis()); setDisplayName(account.getName(), account.getEmojis());
setUsername(account.getUsername()); setUsername(account.getUsername());
setContent( setContent(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<color android:color="@color/ic_launcher_background"/>
</item>
<item>
<bitmap
android:src="@drawable/splash"
android:gravity="center" />
</item>
</layer-list>

View File

@ -331,7 +331,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingBottom="6dp" android:paddingBottom="6dp"
android:text="@string/title_statuses" android:text="@string/title_posts"
android:textColor="@color/account_tab_font_color" android:textColor="@color/account_tab_font_color"
android:textSize="?attr/status_text_medium" /> android:textSize="?attr/status_text_medium" />

View File

@ -371,10 +371,10 @@
android:layout_width="36dp" android:layout_width="36dp"
android:layout_height="36dp" android:layout_height="36dp"
android:layout_marginEnd="4dp" android:layout_marginEnd="4dp"
android:contentDescription="@string/action_schedule_toot" android:contentDescription="@string/action_schedule_post"
android:padding="4dp" android:padding="4dp"
app:srcCompat="@drawable/ic_access_time" app:srcCompat="@drawable/ic_access_time"
app:tooltipText="@string/action_schedule_toot" /> app:tooltipText="@string/action_schedule_post" />
<Space <Space
android:layout_width="0dp" android:layout_width="0dp"

View File

@ -6,7 +6,7 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:gravity="center" android:gravity="center"
android:orientation="vertical" android:orientation="vertical"
tools:context="com.keylesspalace.tusky.LoginActivity"> tools:context="com.keylesspalace.tusky.components.login.LoginActivity">
<ScrollView <ScrollView
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".components.scheduled.ScheduledTootActivity"> tools:context=".components.scheduled.ScheduledStatusActivity">
<include <include
android:id="@+id/includedToolbar" android:id="@+id/includedToolbar"

View File

@ -154,7 +154,7 @@
android:visibility="gone" android:visibility="gone"
app:layout_constraintStart_toStartOf="@id/status_display_name" app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_content_warning_description" app:layout_constraintTop_toBottomOf="@id/status_content_warning_description"
tools:text="@string/status_content_warning_show_more" tools:text="@string/post_content_warning_show_more"
tools:visibility="visible" /> tools:visibility="visible" />
<androidx.emoji.widget.EmojiTextView <androidx.emoji.widget.EmojiTextView
@ -189,7 +189,7 @@
android:visibility="gone" android:visibility="gone"
app:layout_constraintStart_toStartOf="@id/status_display_name" app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_content" app:layout_constraintTop_toBottomOf="@id/status_content"
tools:text="@string/status_content_show_less" tools:text="@string/post_content_show_less"
tools:visibility="visible" /> tools:visibility="visible" />
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout

View File

@ -19,7 +19,7 @@
android:drawablePadding="4dp" android:drawablePadding="4dp"
android:fontFamily="sans-serif-medium" android:fontFamily="sans-serif-medium"
android:gravity="center_vertical" android:gravity="center_vertical"
android:text="@string/drafts_toot_failed_to_send" android:text="@string/drafts_post_failed_to_send"
android:textColor="@color/tusky_red" android:textColor="@color/tusky_red"
android:textSize="?attr/status_text_medium" android:textSize="?attr/status_text_medium"
app:drawableStartCompat="@drawable/ic_alert_circle" app:drawableStartCompat="@drawable/ic_alert_circle"
@ -53,7 +53,7 @@
app:layout_constraintEnd_toStartOf="@id/deleteButton" app:layout_constraintEnd_toStartOf="@id/deleteButton"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/contentWarning" app:layout_constraintTop_toBottomOf="@id/contentWarning"
tools:text="Some toot content. May be very long." /> tools:text="Some post content. May be very long." />
<ImageButton <ImageButton
android:id="@+id/deleteButton" android:id="@+id/deleteButton"

View File

@ -46,7 +46,7 @@
android:visibility="gone" android:visibility="gone"
app:layout_constraintStart_toStartOf="@id/guideBegin" app:layout_constraintStart_toStartOf="@id/guideBegin"
app:layout_constraintTop_toBottomOf="@id/statusContentWarningDescription" app:layout_constraintTop_toBottomOf="@id/statusContentWarningDescription"
tools:text="@string/status_content_warning_show_more" tools:text="@string/post_content_warning_show_more"
tools:visibility="visible" /> tools:visibility="visible" />
<androidx.emoji.widget.EmojiTextView <androidx.emoji.widget.EmojiTextView
@ -79,7 +79,7 @@
android:visibility="gone" android:visibility="gone"
app:layout_constraintStart_toStartOf="@id/guideBegin" app:layout_constraintStart_toStartOf="@id/guideBegin"
app:layout_constraintTop_toBottomOf="@id/statusContent" app:layout_constraintTop_toBottomOf="@id/statusContent"
tools:text="@string/status_content_show_less" tools:text="@string/post_content_show_less"
tools:visibility="visible" /> tools:visibility="visible" />
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout

View File

@ -133,13 +133,13 @@
android:paddingRight="16dp" android:paddingRight="16dp"
android:paddingBottom="4dp" android:paddingBottom="4dp"
android:textAllCaps="true" android:textAllCaps="true"
android:textOff="@string/status_content_warning_show_more" android:textOff="@string/post_content_warning_show_more"
android:textOn="@string/status_content_warning_show_less" android:textOn="@string/post_content_warning_show_less"
android:textSize="?attr/status_text_medium" android:textSize="?attr/status_text_medium"
android:visibility="gone" android:visibility="gone"
app:layout_constraintStart_toStartOf="@id/status_display_name" app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_content_warning_description" app:layout_constraintTop_toBottomOf="@id/status_content_warning_description"
tools:text="@string/status_content_warning_show_more" tools:text="@string/post_content_warning_show_more"
tools:visibility="visible" /> tools:visibility="visible" />
<androidx.emoji.widget.EmojiTextView <androidx.emoji.widget.EmojiTextView
@ -244,7 +244,7 @@
android:visibility="gone" android:visibility="gone"
app:layout_constraintStart_toStartOf="@id/status_display_name" app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_content" app:layout_constraintTop_toBottomOf="@id/status_content"
tools:text="@string/status_content_show_less" tools:text="@string/post_content_show_less"
tools:visibility="visible" /> tools:visibility="visible" />
<include <include

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