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).
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
@ -15,14 +15,14 @@ Tusky is a beautiful Android client for [Mastodon](https://github.com/tootsuite/
- Most Mastodon APIs implemented
- Multi-Account support
- 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
- Optimized for all screen sizes
- Completely open-source - no non-free dependencies like Google services
### 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/

View File

@ -15,11 +15,11 @@ def getGitSha = {
}
android {
compileSdkVersion 30
compileSdkVersion 31
defaultConfig {
applicationId 'net.accelf.yuito'
minSdkVersion 21
targetSdkVersion 30
targetSdkVersion 31
versionCode 43
versionName '4.1.3'
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -96,12 +96,12 @@ android {
}
ext.coroutinesVersion = "1.6.0"
ext.lifecycleVersion = "2.3.1"
ext.roomVersion = '2.3.0'
ext.lifecycleVersion = "2.4.1"
ext.roomVersion = '2.4.2'
ext.retrofitVersion = '2.9.0'
ext.okhttpVersion = '4.9.3'
ext.glideVersion = '4.12.0'
ext.daggerVersion = '2.40.5'
ext.glideVersion = '4.13.1'
ext.daggerVersion = '2.41'
ext.materialdrawerVersion = '8.4.5'
repositories {
@ -117,33 +117,35 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$coroutinesVersion"
implementation "androidx.core:core-ktx:1.5.0"
implementation "androidx.appcompat:appcompat:1.3.0"
implementation "androidx.fragment:fragment-ktx:1.3.4"
implementation "androidx.browser:browser:1.3.0"
implementation "androidx.core:core-ktx:1.7.0"
implementation "androidx.appcompat:appcompat:1.4.1"
implementation "androidx.fragment:fragment-ktx:1.4.1"
implementation "androidx.browser:browser:1.4.0"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.exifinterface:exifinterface:1.3.3"
implementation "androidx.cardview:cardview:1.0.0"
implementation "androidx.preference:preference-ktx:1.1.1"
implementation "androidx.sharetarget:sharetarget:1.1.0"
implementation "androidx.preference:preference-ktx:1.2.0"
implementation "androidx.sharetarget:sharetarget:1.2.0-rc01"
implementation "androidx.emoji:emoji:1.1.0"
implementation "androidx.emoji:emoji-appcompat:1.1.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion"
implementation "androidx.constraintlayout:constraintlayout:2.1.2"
implementation "androidx.paging:paging-runtime-ktx:3.0.0"
implementation "androidx.constraintlayout:constraintlayout:2.1.3"
implementation "androidx.paging:paging-runtime-ktx:3.1.1"
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-paging:$roomVersion"
implementation "androidx.room:room-rxjava3:$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:converter-gson:$retrofitVersion"
@ -158,14 +160,14 @@ dependencies {
implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion"
kapt "com.github.bumptech.glide:compiler:$glideVersion"
implementation "com.github.penfeizhou.android.animation:glide-plugin:2.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:rxkotlin:3.0.1"
implementation "com.uber.autodispose2:autodispose-androidx-lifecycle:2.0.0"
implementation "com.uber.autodispose2:autodispose:2.0.0"
implementation "com.uber.autodispose2:autodispose-androidx-lifecycle:2.1.1"
implementation "com.uber.autodispose2:autodispose:2.1.1"
implementation "com.google.dagger:dagger:$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:theme="@style/TuskyTheme"
android:usesCleartextTraffic="false">
<activity
android:name=".SplashActivity"
android:theme="@style/SplashTheme">
android:name=".components.login.LoginActivity"
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>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</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>
<action android:name="android.intent.action.SEND" />
@ -98,6 +84,9 @@
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/share_shortcuts" />
</activity>
<activity
@ -107,7 +96,6 @@
<activity
android:name=".ViewThreadActivity"
android:configChanges="orientation|screenSize" />
<activity android:name=".ViewTagActivity" />
<activity
android:name=".ViewMediaActivity"
android:theme="@style/TuskyBaseTheme" />
@ -125,7 +113,8 @@
android:theme="@style/Base.Theme.AppCompat" />
<activity
android:name=".components.search.SearchActivity"
android:launchMode="singleTop">
android:launchMode="singleTop"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
@ -135,18 +124,18 @@
android:resource="@xml/searchable" />
</activity>
<activity android:name=".ListsActivity" />
<activity android:name=".ModalTimelineActivity" />
<activity android:name=".LicenseActivity" />
<activity android:name=".FiltersActivity" />
<activity
android:name=".components.report.ReportActivity"
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
<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.drafts.DraftsActivity" />
<receiver android:name=".receiver.NotificationClearBroadcastReceiver" />
<receiver android:name=".receiver.NotificationClearBroadcastReceiver"
android:exported="false" />
<receiver
android:name=".receiver.SendStatusBroadcastReceiver"
android:enabled="true"
@ -155,15 +144,17 @@
<service
android:name=".service.TuskyTileService"
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:exported="true"
tools:targetApi="24">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<service android:name=".service.SendTootService" />
<service android:name=".service.SendStatusService"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
@ -177,10 +168,15 @@
<!-- disable automatic WorkManager initialization -->
<provider
android:name="androidx.work.impl.WorkManagerInitializer"
android:authorities="${applicationId}.workmanager-init"
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
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" />
</application>

View File

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

View File

@ -38,6 +38,7 @@ import androidx.preference.PreferenceManager;
import com.google.android.material.snackbar.Snackbar;
import com.keylesspalace.tusky.adapter.AccountSelectionAdapter;
import com.keylesspalace.tusky.components.login.LoginActivity;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager;
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.core.content.ContextCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.emoji.text.EmojiCompat
import androidx.emoji.text.EmojiCompat.InitCallback
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.drafts.DraftHelper
import com.keylesspalace.tusky.components.drafts.DraftsActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.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.timeline.TimelineFragment
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.schedulers.Schedulers
import kotlinx.coroutines.launch
import net.accelf.yuito.CustomUncaughtExceptionHandler
import net.accelf.yuito.FooterDrawerItem
import net.accelf.yuito.QuickTootViewModel
import net.accelf.yuito.streaming.StreamingManager
@ -185,8 +188,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
override fun onCreate(savedInstanceState: Bundle?) {
Thread.setDefaultUncaughtExceptionHandler(CustomUncaughtExceptionHandler(applicationContext))
installSplashScreen()
super.onCreate(savedInstanceState)
// delete old notification channels
NotificationHelper.deleteLegacyNotificationChannels(this, accountManager)
val activeAccount = accountManager.activeAccount
?: return // will be redirected to LoginActivity by BaseActivity
@ -515,10 +524,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
},
primaryDrawerItem {
nameRes = R.string.action_access_scheduled_toot
nameRes = R.string.action_access_scheduled_posts
iconRes = R.drawable.ic_access_time
onClick = {
startActivityWithSlideInAnimation(ScheduledTootActivity.newIntent(context))
startActivityWithSlideInAnimation(ScheduledStatusActivity.newIntent(context))
}
},
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.webkit.MimeTypeMap
import android.widget.Toast
import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
@ -205,10 +206,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val request = DownloadManager.Request(Uri.parse(url))
request.setDestinationInExternalPublicDir(
Environment.DIRECTORY_PICTURES,
getString(R.string.app_name) + "/" + filename
)
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename)
downloadManager.enqueue(request)
}
@ -255,11 +253,11 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
}
private fun shareFile(file: File, mimeType: String?) {
val sendIntent = Intent()
sendIntent.action = Intent.ACTION_SEND
sendIntent.putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(applicationContext, "$APPLICATION_ID.fileprovider", file))
sendIntent.type = mimeType
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_media_to)))
ShareCompat.IntentBuilder(this)
.setType(mimeType)
.addStream(FileProvider.getUriForFile(applicationContext, "$APPLICATION_ID.fileprovider", file))
.setChooserTitle(R.string.send_media_to)
.startChooser()
}
private var isCreating: Boolean = false

View File

@ -18,7 +18,7 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
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.util.removeDuplicates
@ -28,7 +28,7 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
protected val animateAvatar: Boolean,
protected val animateEmojis: Boolean
) : RecyclerView.Adapter<RecyclerView.ViewHolder?>() {
var accountList = mutableListOf<Account>()
var accountList = mutableListOf<TimelineAccount>()
private var bottomLoading: Boolean = false
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)
notifyDataSetChanged()
}
fun addItems(newAccounts: List<Account>) {
fun addItems(newAccounts: List<TimelineAccount>) {
val end = accountList.size
val last = accountList[end - 1]
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) {
return null
}
@ -109,7 +109,7 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
return account
}
fun addItem(account: Account, position: Int) {
fun addItem(account: TimelineAccount, position: Int) {
if (position < 0 || position > accountList.size) {
return
}

View File

@ -9,7 +9,7 @@ import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.RecyclerView;
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.LinkListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
@ -33,9 +33,9 @@ public class AccountViewHolder extends RecyclerView.ViewHolder {
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();
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());
username.setText(formattedUsername);
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 androidx.recyclerview.widget.RecyclerView
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.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
@ -55,11 +55,11 @@ class BlocksAdapter(
private val unblock: ImageButton = itemView.findViewById(R.id.blocked_user_unblock)
private var id: String? = null
fun setupWithAccount(account: Account, animateAvatar: Boolean, animateEmojis: Boolean) {
fun setupWithAccount(account: TimelineAccount, animateAvatar: Boolean, animateEmojis: Boolean) {
id = account.id
val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis)
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)
username.text = formattedUsername
val avatarRadius = avatar.context.resources

View File

@ -22,7 +22,7 @@ import android.text.style.StyleSpan
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
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.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
@ -34,7 +34,7 @@ class FollowRequestViewHolder(
private val showHeader: Boolean
) : 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 emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis)
binding.displayNameTextView.text = emojifiedName
@ -45,7 +45,7 @@ class FollowRequestViewHolder(
}.emojify(account.emojis, itemView, animateEmojis)
}
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)
binding.usernameTextView.text = formattedUsername
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.recyclerview.widget.RecyclerView
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.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
@ -69,7 +69,7 @@ class MutesAdapter(
private var notifications = false
fun setupWithAccount(
account: Account,
account: TimelineAccount,
mutingNotifications: Boolean?,
animateAvatar: Boolean,
animateEmojis: Boolean
@ -77,7 +77,7 @@ class MutesAdapter(
id = account.id
val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis)
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)
username.text = formattedUsername
val avatarRadius = avatar.context.resources

View File

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

View File

@ -200,7 +200,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
protected void setUsername(String name) {
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);
}
@ -245,9 +245,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private void setContentWarningButtonText(boolean expanded) {
if (expanded) {
contentWarningButton.setText(R.string.status_content_warning_show_less);
contentWarningButton.setText(R.string.post_content_warning_show_less);
} 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) {
sensitiveMediaWarning.setText(R.string.status_sensitive_media_title);
sensitiveMediaWarning.setText(R.string.post_sensitive_media_title);
} else {
sensitiveMediaWarning.setText(R.string.status_media_hidden_title);
sensitiveMediaWarning.setText(R.string.post_media_hidden_title);
}
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) {
Context context = itemView.getContext();
CharSequence label = (sensitive && !showingContent) ?
context.getString(R.string.status_sensitive_media_title) :
context.getString(R.string.post_sensitive_media_title) :
mediaDescriptions[index];
mediaLabels[index].setText(label);
}
@ -687,7 +687,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
duration = formatDuration(attachment.getMeta().getDuration()) + " ";
}
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 {
return duration + attachment.getDescription();
}
@ -935,9 +935,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions),
getReblogDescription(context, status),
status.getUsername(),
actionable.getReblogged() ? context.getString(R.string.description_status_reblogged) : "",
actionable.getFavourited() ? context.getString(R.string.description_status_favourited) : "",
actionable.getBookmarked() ? context.getString(R.string.description_status_bookmarked) : "",
actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "",
actionable.getFavourited() ? context.getString(R.string.description_post_favourited) : "",
actionable.getBookmarked() ? context.getString(R.string.description_post_bookmarked) : "",
getMediaDescription(context, status),
getVisibilityDescription(context, actionable.getVisibility()),
getFavsText(context, actionable.getFavouritesCount()),
@ -952,7 +952,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
Status reblog = status.getRebloggingStatus();
if (reblog != null) {
return context
.getString(R.string.status_boosted_format, reblog.getAccount().getUsername());
.getString(R.string.post_boosted_format, reblog.getAccount().getUsername());
} else {
return "";
}
@ -969,20 +969,20 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
(builder, a) -> {
if (a.getDescription() == null) {
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);
} else {
builder.append("; ");
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,
@NonNull StatusViewData.Concrete status) {
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 {
return "";
}

View File

@ -86,7 +86,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
final StatusDisplayOptions statusDisplayOptions) {
Context context = statusInfo.getContext();
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(
boostedText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis()
);
@ -118,10 +118,10 @@ public class StatusViewHolder extends StatusBaseViewHolder {
contentCollapseButton.setVisibility(View.VISIBLE);
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);
} else {
contentCollapseButton.setText(R.string.status_content_warning_show_less);
contentCollapseButton.setText(R.string.post_content_warning_show_less);
content.setFilters(NO_INPUT_FILTER);
}
} else {

View File

@ -239,7 +239,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountFragmentViewPager.adapter = adapter
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 ->
tab.text = pageTitles[position]
@ -411,7 +411,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
private fun onAccountChanged(account: Account?) {
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.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis)
@ -482,7 +482,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
} catch (e: IllegalStateException) {
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.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)

View File

@ -16,7 +16,9 @@
package com.keylesspalace.tusky.components.compose
import android.Manifest
import android.app.NotificationManager
import android.app.ProgressDialog
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
@ -46,8 +48,8 @@ import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.core.view.inputmethod.InputContentInfoCompat
import androidx.core.view.ContentInfoCompat
import androidx.core.view.OnReceiveContentListener
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
@ -109,7 +111,7 @@ class ComposeActivity :
ComposeAutoCompleteAdapter.AutocompletionProvider,
OnEmojiSelectedListener,
Injectable,
InputConnectionCompat.OnCommitContentListener,
OnReceiveContentListener,
ComposeScheduleView.OnTimeSetListener {
@Inject
@ -155,6 +157,18 @@ class ComposeActivity :
public override fun onCreate(savedInstanceState: Bundle?) {
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 theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT)
if (theme == "black") {
@ -194,9 +208,9 @@ class ComposeActivity :
viewModel.setup(composeOptions)
setupReplyViews(composeOptions?.replyingStatusAuthor, composeOptions?.replyingStatusContent)
setupQuoteView(composeOptions?.quoteStatusAuthor, composeOptions?.quoteStatusContent)
val tootText = composeOptions?.tootText
if (!tootText.isNullOrEmpty()) {
binding.composeEditField.setText(tootText)
val statusContent = composeOptions?.content
if (!statusContent.isNullOrEmpty()) {
binding.composeEditField.setText(statusContent)
}
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 text = intent.getStringExtra(Intent.EXTRA_TEXT).orEmpty()
val shareBody = if (!subject.isNullOrBlank() && subject !in text) {
subject + '\n' + text
} else {
text
}
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
val text = intent.getStringExtra(Intent.EXTRA_TEXT).orEmpty()
val shareBody = if (!subject.isNullOrBlank() && subject !in text) {
subject + '\n' + text
} else {
text
}
if (shareBody.isNotBlank()) {
val start = binding.composeEditField.selectionStart.coerceAtLeast(0)
val end = binding.composeEditField.selectionEnd.coerceAtLeast(0)
val left = min(start, end)
val right = max(start, end)
binding.composeEditField.text.replace(left, right, shareBody, 0, shareBody.length)
// move edittext cursor to first when shareBody parsed
binding.composeEditField.text.insert(0, "\n")
binding.composeEditField.setSelection(0)
}
if (shareBody.isNotBlank()) {
val start = binding.composeEditField.selectionStart.coerceAtLeast(0)
val end = binding.composeEditField.selectionEnd.coerceAtLeast(0)
val left = min(start, end)
val right = max(start, end)
binding.composeEditField.text.replace(left, right, shareBody, 0, shareBody.length)
// move edittext cursor to first when shareBody parsed
binding.composeEditField.text.insert(0, "\n")
binding.composeEditField.setSelection(0)
}
}
}
@ -336,7 +349,7 @@ class ComposeActivity :
}
private fun setupComposeField(preferences: SharedPreferences, startingText: String?) {
binding.composeEditField.setOnCommitContentListener(this)
binding.composeEditField.setOnReceiveContentListener(this)
binding.composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) }
@ -776,7 +789,9 @@ class ComposeActivity :
val urlSpans = binding.composeEditField.urls
if (urlSpans != null) {
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
@ -819,26 +834,18 @@ class ComposeActivity :
}
}
/** This is for the fancy keyboards which can insert images and stuff. */
override fun onCommitContent(inputContentInfo: InputContentInfoCompat, flags: Int, opts: Bundle?): Boolean {
// Verify the returned content's type is of the correct MIME type
val supported = inputContentInfo.description.hasMimeType("image/*")
if (supported) {
val lacksPermission = (flags and InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0
if (lacksPermission) {
try {
inputContentInfo.requestPermission()
} catch (e: Exception) {
Log.e(TAG, "InputContentInfoCompat#requestPermission() failed." + e.message)
return false
/** This is for the fancy keyboards which can insert images and stuff, and drag&drop etc */
override fun onReceiveContent(view: View, contentInfo: ContentInfoCompat): ContentInfoCompat? {
if (contentInfo.clip.description.hasMimeType("image/*")) {
val split = contentInfo.partition { item: ClipData.Item -> item.uri != null }
split.first?.let { content ->
for (i in 0 until content.clip.itemCount) {
pickMedia(content.clip.getItemAt(i).uri)
}
}
pickMedia(inputContentInfo.contentUri, inputContentInfo)
return true
return split.second
}
return false
return contentInfo
}
private fun sendStatus() {
@ -865,12 +872,11 @@ class ComposeActivity :
}
viewModel.sendStatus(contentText, spoilerText).observe(
this,
{
finishingUploadDialog?.dismiss()
deleteDraftAndFinish()
}
)
this
) {
finishingUploadDialog?.dismiss()
deleteDraftAndFinish()
}
} else {
binding.composeEditField.error = getString(R.string.error_compose_character_limit)
enableButtons(true)
@ -940,12 +946,9 @@ class ComposeActivity :
viewModel.removeMediaFromQueue(item)
}
private fun pickMedia(uri: Uri, contentInfoCompat: InputContentInfoCompat? = null) {
private fun pickMedia(uri: Uri) {
withLifecycleContext {
viewModel.pickMedia(uri).observe { exceptionOrItem ->
contentInfoCompat?.releasePermission()
exceptionOrItem.asLeftOrNull()?.let {
val errorId = when (it) {
is VideoSizeException -> {
@ -1098,29 +1101,29 @@ class ComposeActivity :
@Parcelize
data class ComposeOptions(
// Let's keep fields var until all consumers are Kotlin
var scheduledTootId: String? = null,
var draftId: Int? = null,
var tootText: String? = null,
var mediaUrls: List<String>? = null,
var mediaDescriptions: List<String>? = null,
var mentionedUsernames: Set<String>? = null,
var inReplyToId: String? = null,
var quoteId: String? = null,
var quoteStatusAuthor: String? = null,
var quoteStatusContent: String? = null,
var replyVisibility: Status.Visibility? = null,
var visibility: Status.Visibility? = null,
var contentWarning: String? = null,
var replyingStatusAuthor: String? = null,
var replyingStatusContent: String? = null,
var mediaAttachments: List<Attachment>? = null,
var draftAttachments: List<DraftAttachment>? = null,
var scheduledAt: String? = null,
var sensitive: Boolean? = null,
var poll: NewPoll? = null,
var modifiedInitialState: Boolean? = null,
var tootRightNow: Boolean? = null
// Let's keep fields var until all consumers are Kotlin
var scheduledTootId: String? = null,
var draftId: Int? = null,
var content: String? = null,
var mediaUrls: List<String>? = null,
var mediaDescriptions: List<String>? = null,
var mentionedUsernames: Set<String>? = null,
var inReplyToId: String? = null,
var quoteId: String? = null,
var quoteStatusAuthor: String? = null,
var quoteStatusContent: String? = null,
var replyVisibility: Status.Visibility? = null,
var visibility: Status.Visibility? = null,
var contentWarning: String? = null,
var replyingStatusAuthor: String? = null,
var replyingStatusContent: String? = null,
var mediaAttachments: List<Attachment>? = null,
var draftAttachments: List<DraftAttachment>? = null,
var scheduledAt: String? = null,
var sensitive: Boolean? = null,
var poll: NewPoll? = null,
var modifiedInitialState: Boolean? = null,
var tootRightNow: Boolean? = null,
) : Parcelable
companion object {
@ -1128,6 +1131,8 @@ class ComposeActivity :
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
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"
@JvmField
@ -1136,10 +1141,28 @@ class ComposeActivity :
const val PREF_DEFAULT_TAG = "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
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 {
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;
import android.content.Context;
import android.preference.PreferenceManager;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@ -28,9 +27,9 @@ import android.widget.TextView;
import com.bumptech.glide.Glide;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.HashTag;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
@ -144,9 +143,9 @@ public class ComposeAutoCompleteAdapter extends BaseAdapter
AccountResult accountResult = ((AccountResult) getItem(position));
if (accountResult != null) {
Account account = accountResult.account;
TimelineAccount account = accountResult.account;
String formattedUsername = context.getString(
R.string.status_username_format,
R.string.post_username_format,
account.getUsername()
);
accountViewHolder.username.setText(formattedUsername);
@ -268,9 +267,9 @@ public class ComposeAutoCompleteAdapter extends BaseAdapter
}
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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -154,10 +154,10 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
contentCollapseButton.setVisibility(View.VISIBLE);
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);
} else {
contentCollapseButton.setText(R.string.status_content_warning_show_less);
contentCollapseButton.setText(R.string.post_content_warning_show_less);
content.setFilters(NO_INPUT_FILTER);
}
} else {

View File

@ -90,14 +90,14 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
if (draft.inReplyToId != null) {
bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED
viewModel.getToot(draft.inReplyToId)
viewModel.getStatus(draft.inReplyToId)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this))
.subscribe(
{ status ->
val composeOptions = ComposeActivity.ComposeOptions(
draftId = draft.id,
tootText = draft.content,
content = draft.content,
contentWarning = draft.contentWarning,
inReplyToId = draft.inReplyToId,
replyingStatusContent = status.content.toString(),
@ -121,7 +121,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
if (throwable is HttpException && throwable.code() == 404) {
// the original status to which a reply was drafted has been deleted
// 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)
} else {
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) {
val composeOptions = ComposeActivity.ComposeOptions(
draftId = draft.id,
tootText = draft.content,
content = draft.content,
contentWarning = draft.contentWarning,
draftAttachments = draft.attachments,
poll = draft.poll,

View File

@ -60,8 +60,8 @@ class DraftsViewModel @Inject constructor(
}
}
fun getToot(tootId: String): Single<Status> {
return api.status(tootId)
fun getStatus(statusId: String): Single<Status> {
return api.status(statusId)
}
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.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.os.Build;
import android.provider.Settings;
import android.text.TextUtils;
@ -46,9 +45,9 @@ import androidx.work.WorkRequest;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
import com.bumptech.glide.request.FutureTarget;
import com.keylesspalace.tusky.BuildConfig;
import com.keylesspalace.tusky.MainActivity;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.components.compose.ComposeActivity;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.entity.Notification;
@ -67,6 +66,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
@ -88,8 +88,6 @@ public class NotificationHelper {
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_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_CITED_TEXT = "KEY_CITED_TEXT";
public static final String KEY_CITED_AUTHOR_LOCAL = "KEY_CITED_AUTHOR_LOCAL";
/**
* notification channels used on Android O+
**/
@ -206,21 +200,24 @@ public class NotificationHelper {
.setLabel(context.getString(R.string.label_quick_reply))
.build();
PendingIntent quickReplyPendingIntent = getStatusReplyIntent(REPLY_ACTION, context, body, account);
PendingIntent quickReplyPendingIntent = getStatusReplyIntent(context, body, account);
NotificationCompat.Action quickReplyAction =
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)
.build();
builder.addAction(quickReplyAction);
PendingIntent composePendingIntent = getStatusReplyIntent(COMPOSE_ACTION, context, body, account);
PendingIntent composeIntent = getStatusComposeIntent(context, body, account);
NotificationCompat.Action composeAction =
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();
builder.addAction(composeAction);
@ -237,7 +234,6 @@ public class NotificationHelper {
}
// Summary
// =======
final NotificationCompat.Builder summaryBuilder = newNotification(context, body, account, true);
if (currentNotifications.length() != 1) {
@ -275,7 +271,7 @@ public class NotificationHelper {
summaryStackBuilder.addNextIntent(summaryResultIntent);
PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent((int) (notificationId + account.getId() * 10000),
PendingIntent.FLAG_UPDATE_CURRENT);
pendingIntentFlags(false));
// we have to switch account here
Intent eventResultIntent = new Intent(context, MainActivity.class);
@ -285,18 +281,18 @@ public class NotificationHelper {
eventStackBuilder.addNextIntent(eventResultIntent);
PendingIntent eventResultPendingIntent = eventStackBuilder.getPendingIntent((int) account.getId(),
PendingIntent.FLAG_UPDATE_CURRENT);
pendingIntentFlags(false));
Intent deleteIntent = new Intent(context, NotificationClearBroadcastReceiver.class);
deleteIntent.putExtra(ACCOUNT_ID, account.getId());
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))
.setSmallIcon(R.drawable.ic_notify)
.setContentIntent(summary ? summaryResultPendingIntent : eventResultPendingIntent)
.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())
.setAutoCancel(true)
.setShortcutId(Long.toString(account.getId()))
@ -307,11 +303,9 @@ public class NotificationHelper {
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();
String citedLocalAuthor = status.getAccount().getLocalUsername();
String citedText = status.getContent().toString();
String inReplyToId = status.getId();
Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility();
@ -326,9 +320,7 @@ public class NotificationHelper {
mentionedUsernames = new ArrayList<>(new LinkedHashSet<>(mentionedUsernames));
Intent replyIntent = new Intent(context, SendStatusBroadcastReceiver.class)
.setAction(action)
.putExtra(KEY_CITED_AUTHOR_LOCAL, citedLocalAuthor)
.putExtra(KEY_CITED_TEXT, citedText)
.setAction(REPLY_ACTION)
.putExtra(KEY_SENDER_ACCOUNT_ID, account.getId())
.putExtra(KEY_SENDER_ACCOUNT_IDENTIFIER, account.getIdentifier())
.putExtra(KEY_SENDER_ACCOUNT_FULL_NAME, account.getFullName())
@ -341,7 +333,50 @@ public class NotificationHelper {
return PendingIntent.getBroadcast(context.getApplicationContext(),
notificationId,
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) {
@ -409,9 +444,7 @@ public class NotificationHelper {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
//noinspection ConstantConditions
notificationManager.deleteNotificationChannelGroup(account.getIdentifier());
}
}
@ -421,7 +454,6 @@ public class NotificationHelper {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
// used until Tusky 1.4
//noinspection ConstantConditions
notificationManager.deleteNotificationChannel(CHANNEL_MENTION);
notificationManager.deleteNotificationChannel(CHANNEL_FAVOURITE);
notificationManager.deleteNotificationChannel(CHANNEL_BOOST);
@ -440,7 +472,6 @@ public class NotificationHelper {
// on Android >= O, notifications are enabled, if at least one channel is enabled
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
//noinspection ConstantConditions
if (notificationManager.areNotificationsEnabled()) {
for (NotificationChannel channel : notificationManager.getNotificationChannels()) {
if (channel.getImportance() > NotificationManager.IMPORTANCE_NONE) {
@ -491,7 +522,6 @@ public class NotificationHelper {
accountManager.saveAccount(account);
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
//noinspection ConstantConditions
notificationManager.cancel((int) account.getId());
return true;
})
@ -511,7 +541,6 @@ public class NotificationHelper {
// unknown notificationtype
return false;
}
//noinspection ConstantConditions
NotificationChannel channel = notificationManager.getNotificationChannel(channelId);
return channel.getImportance() > NotificationManager.IMPORTANCE_NONE;
}
@ -674,4 +703,11 @@ public class NotificationHelper {
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.preference.Preference
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.MainActivity
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.ItemEmojiPrefBinding
import com.keylesspalace.tusky.util.EmojiCompatFont
@ -215,12 +216,12 @@ class EmojiPreference(
.setPositiveButton(R.string.restart) { _, _ ->
// Restart the app
// 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(
context,
0x1f973, // This is the codepoint of the party face emoji :D
launchIntent,
PendingIntent.FLAG_CANCEL_CURRENT
NotificationHelper.pendingIntentFlags(false)
)
val mgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
mgr.set(

View File

@ -78,7 +78,7 @@ class PreferencesActivity :
NotificationPreferencesFragment.newInstance()
}
TAB_FILTER_PREFERENCES -> {
setTitle(R.string.pref_title_status_tabs)
setTitle(R.string.pref_title_post_tabs)
TabFilterPreferencesFragment.newInstance()
}
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.di.Injectable
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.deserialize
import com.keylesspalace.tusky.util.getNonNullString
@ -85,11 +92,11 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
listPreference {
setDefaultValue("medium")
setEntries(R.array.status_text_size_names)
setEntryValues(R.array.status_text_size_values)
setEntries(R.array.post_text_size_names)
setEntryValues(R.array.post_text_size_values)
key = PrefKeys.STATUS_TEXT_SIZE
setSummaryProvider { entry }
setTitle(R.string.pref_status_text_size)
setTitle(R.string.pref_post_text_size)
icon = makeIcon(GoogleMaterial.Icon.gmd_format_size)
}
@ -137,6 +144,13 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
isSingleLineTitle = false
}
switchPreference {
setDefaultValue(false)
key = PrefKeys.ANIMATE_CUSTOM_EMOJIS
setTitle(R.string.pref_title_animate_custom_emojis)
isSingleLineTitle = false
}
switchPreference {
setDefaultValue(true)
key = PrefKeys.USE_BLURHASH
@ -179,13 +193,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
isSingleLineTitle = false
}
switchPreference {
setDefaultValue(false)
key = PrefKeys.ANIMATE_CUSTOM_EMOJIS
setTitle(R.string.pref_title_animate_custom_emojis)
isSingleLineTitle = false
}
switchPreference {
setDefaultValue(true)
key = PrefKeys.USE_QUICK_TOOT
@ -232,7 +239,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
preferenceCategory(R.string.pref_title_timeline_filters) {
preference {
setTitle(R.string.pref_title_status_tabs)
setTitle(R.string.pref_title_post_tabs)
setOnPreferenceClickListener {
activity?.let { activity ->
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 ->
preferenceCategory(R.string.pref_title_stacktrace) {
preference {
@ -313,7 +320,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
setOnPreferenceClickListener {
activity?.let { activity ->
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"
))
activity.startActivity(intent)
@ -350,23 +357,24 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
}
private fun updateHttpProxySummary() {
val sharedPreferences = preferenceManager.sharedPreferences
val httpProxyEnabled = sharedPreferences.getBoolean(PrefKeys.HTTP_PROXY_ENABLED, false)
val httpServer = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_SERVER, "")
preferenceManager.sharedPreferences?.let { sharedPreferences ->
val httpProxyEnabled = sharedPreferences.getBoolean(PrefKeys.HTTP_PROXY_ENABLED, false)
val httpServer = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_SERVER, "")
try {
val httpPort = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_PORT, "-1")
.toInt()
try {
val httpPort = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_PORT, "-1")
.toInt()
if (httpProxyEnabled && httpServer.isNotBlank() && httpPort > 0 && httpPort < 65535) {
httpProxyPref?.summary = "$httpServer:$httpPort"
return
if (httpProxyEnabled && httpServer.isNotBlank() && httpPort > 0 && httpPort < 65535) {
httpProxyPref?.summary = "$httpServer:$httpPort"
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 {

View File

@ -123,9 +123,9 @@ class StatusViewHolder(
private fun setContentWarningButtonText(contentShown: Boolean) {
if (contentShown) {
binding.statusContentWarningButton.setText(R.string.status_content_warning_show_less)
binding.statusContentWarningButton.setText(R.string.post_content_warning_show_less)
} 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()
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
} 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
}
} else {

View File

@ -154,7 +154,7 @@ class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Inje
private fun showError() {
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) {
adapter.retry()
}

View File

@ -29,7 +29,7 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.StatusScheduledEvent
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.ViewModelFactory
import com.keylesspalace.tusky.entity.ScheduledStatus
@ -40,7 +40,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injectable {
class ScheduledStatusActivity : BaseActivity(), ScheduledStatusActionListener, Injectable {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@ -48,19 +48,19 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec
@Inject
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?) {
super.onCreate(savedInstanceState)
val binding = ActivityScheduledTootBinding.inflate(layoutInflater)
val binding = ActivityScheduledStatusBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.run {
title = getString(R.string.title_scheduled_toot)
title = getString(R.string.title_scheduled_posts)
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
@ -94,7 +94,7 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec
if (loadState.refresh is LoadState.NotLoading) {
binding.progressBar.hide()
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()
} else {
binding.errorMessageView.hide()
@ -121,7 +121,7 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec
this,
ComposeActivity.ComposeOptions(
scheduledTootId = item.id,
tootText = item.params.text,
content = item.params.text,
contentWarning = item.params.spoilerText,
mediaAttachments = item.mediaAttachments,
inReplyToId = item.params.inReplyToId,
@ -138,6 +138,6 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec
}
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 androidx.paging.PagingDataAdapter
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.util.BindingHolder
interface ScheduledTootActionListener {
interface ScheduledStatusActionListener {
fun edit(item: ScheduledStatus)
fun delete(item: ScheduledStatus)
}
class ScheduledTootAdapter(
val listener: ScheduledTootActionListener
) : PagingDataAdapter<ScheduledStatus, BindingHolder<ItemScheduledTootBinding>>(
class ScheduledStatusAdapter(
val listener: ScheduledStatusActionListener
) : PagingDataAdapter<ScheduledStatus, BindingHolder<ItemScheduledStatusBinding>>(
object : DiffUtil.ItemCallback<ScheduledStatus>() {
override fun areItemsTheSame(oldItem: ScheduledStatus, newItem: ScheduledStatus): Boolean {
return oldItem.id == newItem.id
@ -43,12 +43,12 @@ class ScheduledTootAdapter(
}
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemScheduledTootBinding> {
val binding = ItemScheduledTootBinding.inflate(LayoutInflater.from(parent.context), parent, false)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemScheduledStatusBinding> {
val binding = ItemScheduledStatusBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding)
}
override fun onBindViewHolder(holder: BindingHolder<ItemScheduledTootBinding>, position: Int) {
override fun onBindViewHolder(holder: BindingHolder<ItemScheduledStatusBinding>, position: Int) {
getItem(position)?.let { item ->
holder.binding.edit.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 kotlinx.coroutines.rx3.await
class ScheduledTootPagingSourceFactory(
class ScheduledStatusPagingSourceFactory(
private val mastodonApi: MastodonApi
) : () -> ScheduledTootPagingSource {
) : () -> ScheduledStatusPagingSource {
private val scheduledTootsCache = mutableListOf<ScheduledStatus>()
private var pagingSource: ScheduledTootPagingSource? = null
private var pagingSource: ScheduledStatusPagingSource? = null
override fun invoke(): ScheduledTootPagingSource {
return ScheduledTootPagingSource(mastodonApi, scheduledTootsCache).also {
override fun invoke(): ScheduledStatusPagingSource {
return ScheduledStatusPagingSource(mastodonApi, scheduledTootsCache).also {
pagingSource = it
}
}
@ -42,9 +42,9 @@ class ScheduledTootPagingSourceFactory(
}
}
class ScheduledTootPagingSource(
class ScheduledStatusPagingSource(
private val mastodonApi: MastodonApi,
private val scheduledTootsCache: MutableList<ScheduledStatus>
private val scheduledStatusesCache: MutableList<ScheduledStatus>
) : PagingSource<String, ScheduledStatus>() {
override fun getRefreshKey(state: PagingState<String, ScheduledStatus>): String? {
@ -52,11 +52,11 @@ class ScheduledTootPagingSource(
}
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(
data = scheduledTootsCache,
data = scheduledStatusesCache,
prevKey = null,
nextKey = scheduledTootsCache.lastOrNull()?.id
nextKey = scheduledStatusesCache.lastOrNull()?.id
)
} else {
try {
@ -71,7 +71,7 @@ class ScheduledTootPagingSource(
nextKey = result.lastOrNull()?.id
)
} catch (e: Exception) {
Log.w("ScheduledTootPgngSrc", "Error loading scheduled statuses", e)
Log.w("ScheduledStatuses", "Error loading scheduled statuses", e)
LoadResult.Error(e)
}
}

View File

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

View File

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

View File

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

View File

@ -19,12 +19,12 @@ import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter
import androidx.preference.PreferenceManager
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 kotlinx.coroutines.flow.Flow
class SearchAccountsFragment : SearchFragment<Account>() {
override fun createAdapter(): PagingDataAdapter<Account, *> {
class SearchAccountsFragment : SearchFragment<TimelineAccount>() {
override fun createAdapter(): PagingDataAdapter<TimelineAccount, *> {
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
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
companion object {

View File

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

View File

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

View File

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

View File

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

View File

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

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.keylesspalace.tusky.components.timeline.Placeholder
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.AppDatabase
import com.keylesspalace.tusky.db.TimelineStatusEntity
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.dec
import kotlinx.coroutines.rx3.await
import retrofit2.HttpException
@ -101,15 +101,22 @@ class CachedTimelineRemoteMediator(
db.withTransaction {
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(
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())
} 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.toEntity
import com.keylesspalace.tusky.components.timeline.toViewData
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Poll
@ -41,8 +42,6 @@ import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.dec
import com.keylesspalace.tusky.util.inc
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.map
@ -151,9 +150,11 @@ class CachedTimelineViewModel @Inject constructor(
timelineDao.insertStatus(Placeholder(placeholderId, loading = true).toEntity(activeAccount.id))
val nextPlaceholderId = timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId)
val response = api.homeTimeline(maxId = placeholderId.inc(), sinceId = nextPlaceholderId, limit = LOAD_AT_ONCE).await()
val response = db.withTransaction {
val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId)
val nextPlaceholderId = timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId)
api.homeTimeline(maxId = idAbovePlaceholder, sinceId = nextPlaceholderId, limit = LOAD_AT_ONCE)
}.await()
val statuses = response.body()
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(
Placeholder(statuses.last().id.dec(), loading = false).toEntity(activeAccount.id)
Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id)
)
}
}
} catch (e: Exception) {
loadMoreFailed(placeholderId, e)
ifExpected(e) {
loadMoreFailed(placeholderId, e)
}
}
}
}
@ -228,9 +236,9 @@ class CachedTimelineViewModel @Inject constructor(
db.withTransaction {
if (isFirstOfStreaming) {
val placeholderId = status.id.dec()
timelineDao.insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id))
timelineDao.insertStatus(Placeholder(status.id, loading = false).toEntity(activeAccount.id))
isFirstOfStreaming = false
return@withTransaction
}
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.PagingState
import androidx.paging.RemoteMediator
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.dec
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import retrofit2.HttpException
@ -92,7 +92,7 @@ class NetworkTimelineRemoteMediator(
viewModel.statusData.addAll(0, data)
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 {
val linkHeader = statusResponse.headers()["Link"]
@ -107,7 +107,9 @@ class NetworkTimelineRemoteMediator(
viewModel.currentSource?.invalidate()
return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty())
} 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.PinEvent
import com.keylesspalace.tusky.appstore.ReblogEvent
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.dec
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.viewdata.StatusViewData
import kotlinx.coroutines.flow.map
@ -45,6 +46,7 @@ import kotlinx.coroutines.rx3.await
import net.accelf.yuito.streaming.StreamingManager
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
/**
@ -140,8 +142,10 @@ class NetworkTimelineViewModel @Inject constructor(
statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId }
statusData[placeholderIndex] = StatusViewData.Placeholder(placeholderId, isLoading = true)
val idAbovePlaceholder = statusData.getOrNull(placeholderIndex - 1)?.id
val statusResponse = fetchStatusesForKind(
fromId = placeholderId.inc(),
fromId = idAbovePlaceholder,
uptoId = null,
limit = 20
)
@ -155,7 +159,7 @@ class NetworkTimelineViewModel @Inject constructor(
statusData.removeAt(placeholderIndex)
val activeAccount = accountManager.activeAccount!!
val data = statuses.map { status ->
val data: MutableList<StatusViewData> = statuses.map { status ->
status.toViewData(
isShowingContent = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
isExpanded = activeAccount.alwaysOpenSpoiler,
@ -164,30 +168,31 @@ class NetworkTimelineViewModel @Inject constructor(
}.toMutableList()
if (statuses.isNotEmpty()) {
val firstId = statuses.first().id.hashCode().toLong()
val lastId = statuses.last().id.hashCode().toLong()
val overlappedFrom = statusData.indexOfFirst { it.viewDataId <= firstId }
val overlappedTo = statusData.indexOfFirst { it.viewDataId < lastId }
val firstId = statuses.first().id
val lastId = statuses.last().id
val overlappedFrom = statusData.indexOfFirst { it.asStatusOrNull()?.id?.isLessThanOrEqual(firstId) ?: false }
val overlappedTo = statusData.indexOfFirst { it.asStatusOrNull()?.id?.isLessThan(lastId) ?: false }
if (overlappedFrom < overlappedTo) {
repeat(overlappedTo - overlappedFrom) {
statusData[overlappedFrom].asStatusOrNull()?.let { oldStatus ->
val dataIndex = statuses.indexOfFirst { it.id == oldStatus.id }
if (dataIndex == -1) {
return@let
}
data[dataIndex] = data[dataIndex]
data.mapIndexed { i, status -> i to statusData.firstOrNull { it.asStatusOrNull()?.id == status.id }?.asStatusOrNull() }
.filter { (_, oldStatus) -> oldStatus != null }
.forEach { (i, oldStatus) ->
data[i] = data[i].asStatusOrNull()!!
.copy(
isShowingContent = oldStatus.isShowingContent,
isShowingContent = oldStatus!!.isShowingContent,
isExpanded = oldStatus.isExpanded,
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 {
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()
} catch (e: Exception) {
loadMoreFailed(placeholderId, e)
ifExpected(e) {
loadMoreFailed(placeholderId, e)
}
}
}
}
@ -239,27 +246,27 @@ class NetworkTimelineViewModel @Inject constructor(
val activeAccount = accountManager.activeAccount!!
if (isFirstOfStreaming) {
val placeholderId = status.id.dec()
statusData.add(0, StatusViewData.Placeholder(placeholderId, isLoading = false))
statusData.add(0, StatusViewData.Placeholder(status.id, isLoading = 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()
}
}
override fun fullReload() {
nextKey = statusData.firstOrNull { it is StatusViewData.Concrete }?.asStatusOrNull()?.id?.inc()
nextKey = statusData.firstOrNull { it is StatusViewData.Concrete }?.asStatusOrNull()?.id
statusData.clear()
currentSource?.invalidate()
}
@Throws(IOException::class, HttpException::class)
suspend fun fetchStatusesForKind(
fromId: String?,
uptoId: String?,

View File

@ -20,7 +20,21 @@ import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.entity.Filter
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.StreamingManager
import net.accelf.yuito.streaming.Subscription
import retrofit2.HttpException
import java.io.IOException
abstract class TimelineViewModel(
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 {
private const val TAG = "TimelineVM"
internal const val LOAD_AT_ONCE = 30

View File

@ -29,10 +29,9 @@ import java.io.File;
/**
* DB version & declare DAO
*/
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class
}, version = 30)
}, version = 31)
public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao();
@ -474,4 +473,14 @@ public abstract class AppDatabase extends RoomDatabase {
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")
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")
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.LicenseActivity
import com.keylesspalace.tusky.ListsActivity
import com.keylesspalace.tusky.LoginActivity
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.SplashActivity
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.TabPreferenceActivity
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.drafts.DraftsActivity
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.report.ReportActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
import com.keylesspalace.tusky.components.search.SearchActivity
import dagger.Module
import dagger.android.ContributesAndroidInjector
@ -86,7 +86,7 @@ abstract class ActivitiesModule {
abstract fun contributesLoginActivity(): LoginActivity
@ContributesAndroidInjector
abstract fun contributesSplashActivity(): SplashActivity
abstract fun contributesLoginWebViewActivity(): LoginWebViewActivity
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesPreferencesActivity(): PreferencesActivity
@ -110,7 +110,7 @@ abstract class ActivitiesModule {
abstract fun contributesInstanceListActivity(): InstanceListActivity
@ContributesAndroidInjector
abstract fun contributesScheduledTootActivity(): ScheduledTootActivity
abstract fun contributesScheduledStatusActivity(): ScheduledStatusActivity
@ContributesAndroidInjector
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.Migration25_26(appContext.getExternalFilesDir("Tusky")),
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()
}

View File

@ -15,12 +15,12 @@
package com.keylesspalace.tusky.di
import com.keylesspalace.tusky.service.SendTootService
import com.keylesspalace.tusky.service.SendStatusService
import dagger.Module
import dagger.android.ContributesAndroidInjector
@Module
abstract class ServicesModule {
@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.drafts.DraftsViewModel
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.timeline.viewmodel.CachedTimelineViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
@ -86,8 +86,8 @@ abstract class ViewModelModule {
@Binds
@IntoMap
@ViewModelKey(ScheduledTootViewModel::class)
internal abstract fun scheduledTootViewModel(viewModel: ScheduledTootViewModel): ViewModel
@ViewModelKey(ScheduledStatusViewModel::class)
internal abstract fun scheduledStatusViewModel(viewModel: ScheduledStatusViewModel): ViewModel
@Binds
@IntoMap

View File

@ -17,7 +17,7 @@ package com.keylesspalace.tusky.entity
import android.text.Spanned
import com.google.gson.annotations.SerializedName
import java.util.*
import java.util.Date
data class Account(
val id: String,
@ -53,37 +53,57 @@ data class Account(
val intentionallyUseDisplayName: String
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
/**
* 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(

View File

@ -19,7 +19,7 @@ import com.google.gson.annotations.SerializedName
data class Conversation(
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
val unread: Boolean
)

View File

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

View File

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

View File

@ -25,7 +25,7 @@ import java.util.Date
data class Status(
val id: String,
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_account_id") val inReplyToAccountId: String?,
val reblog: Status?,
@ -158,6 +158,71 @@ data class Status(
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(
val id: 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.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.PrefKeys
@ -255,7 +255,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
followRequestsAdapter.removeItem(position)
}
private fun getFetchCallByListType(fromId: String?): Single<Response<List<Account>>> {
private fun getFetchCallByListType(fromId: String?): Single<Response<List<TimelineAccount>>> {
return when (type) {
Type.FOLLOWS -> {
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)
val links = HttpHeaderLink.parse(linkHeader)

View File

@ -243,7 +243,7 @@ public abstract class SFragment extends Fragment implements Injectable {
popup.setOnMenuItemClickListener(item -> {
switch (item.getItemId()) {
case R.id.status_share_content: {
case R.id.post_share_content: {
Status statusToShare = status;
if (statusToShare.getReblog() != null)
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_SUBJECT, statusUrl);
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;
}
case R.id.status_share_link: {
case R.id.post_share_link: {
Intent sendIntent = new Intent();
sendIntent.setAction(Intent.ACTION_SEND);
sendIntent.putExtra(Intent.EXTRA_TEXT, statusUrl);
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;
}
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) {
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) -> {
timelineCases.delete(id)
.observeOn(AndroidSchedulers.mainThread())
@ -430,7 +430,7 @@ public abstract class SFragment extends Fragment implements Injectable {
return;
}
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) -> {
timelineCases.delete(id)
.observeOn(AndroidSchedulers.mainThread())
@ -442,7 +442,7 @@ public abstract class SFragment extends Fragment implements Injectable {
deletedStatus = status.toDeletedStatus();
}
ComposeOptions composeOptions = new ComposeOptions();
composeOptions.setTootText(deletedStatus.getText());
composeOptions.setContent(deletedStatus.getText());
composeOptions.setInReplyToId(deletedStatus.getInReplyToId());
composeOptions.setVisibility(deletedStatus.getVisibility());
composeOptions.setContentWarning(deletedStatus.getSpoilerText());

View File

@ -16,8 +16,10 @@
package com.keylesspalace.tusky.json
import android.text.Spanned
import android.text.SpannedString
import androidx.core.text.HtmlCompat
import androidx.core.text.parseAsHtml
import androidx.core.text.toHtml
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
@ -32,16 +34,29 @@ import java.lang.reflect.Type
class SpannedTypeAdapter : JsonDeserializer<Spanned>, JsonSerializer<Spanned?> {
@Throws(JsonParseException::class)
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Spanned {
/* Html.fromHtml returns trailing whitespace if the html ends in a </p> tag, which
* all status contents do, so it should be trimmed. */
return Jsoup.parse(json.asString ?: "")
.apply {
select(".quote-inline").forEach { it.remove() }
}
.html().parseAsHtml().trimTrailingWhitespace()
return json.asString
/* Mastodon uses 'white-space: pre-wrap;' so spaces are displayed as returned by the Api.
* We can't use CSS so we replace spaces with non-breaking-spaces to emulate the behavior.
*/
?.replace("<br> ", "<br>&nbsp;")
?.replace("<br /> ", "<br />&nbsp;")
?.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 {
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.Status
import com.keylesspalace.tusky.entity.StatusContext
import com.keylesspalace.tusky.entity.TimelineAccount
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import okhttp3.MultipartBody
@ -178,13 +179,13 @@ interface MastodonApi {
fun statusRebloggedBy(
@Path("id") statusId: String,
@Query("max_id") maxId: String?
): Single<Response<List<Account>>>
): Single<Response<List<TimelineAccount>>>
@GET("api/v1/statuses/{id}/favourited_by")
fun statusFavouritedBy(
@Path("id") statusId: String,
@Query("max_id") maxId: String?
): Single<Response<List<Account>>>
): Single<Response<List<TimelineAccount>>>
@DELETE("api/v1/statuses/{id}")
fun deleteStatus(
@ -286,7 +287,7 @@ interface MastodonApi {
@Query("resolve") resolve: Boolean? = null,
@Query("limit") limit: Int? = null,
@Query("following") following: Boolean? = null
): Single<List<Account>>
): Single<List<TimelineAccount>>
@GET("api/v1/accounts/{id}")
fun account(
@ -317,13 +318,13 @@ interface MastodonApi {
fun accountFollowers(
@Path("id") accountId: String,
@Query("max_id") maxId: String?
): Single<Response<List<Account>>>
): Single<Response<List<TimelineAccount>>>
@GET("api/v1/accounts/{id}/following")
fun accountFollowing(
@Path("id") accountId: String,
@Query("max_id") maxId: String?
): Single<Response<List<Account>>>
): Single<Response<List<TimelineAccount>>>
@FormUrlEncoded
@POST("api/v1/accounts/{id}/follow")
@ -384,12 +385,12 @@ interface MastodonApi {
@GET("api/v1/blocks")
fun blocks(
@Query("max_id") maxId: String?
): Single<Response<List<Account>>>
): Single<Response<List<TimelineAccount>>>
@GET("api/v1/mutes")
fun mutes(
@Query("max_id") maxId: String?
): Single<Response<List<Account>>>
): Single<Response<List<TimelineAccount>>>
@GET("api/v1/domain_blocks")
fun domainBlocks(
@ -426,7 +427,7 @@ interface MastodonApi {
@GET("api/v1/follow_requests")
fun followRequests(
@Query("max_id") maxId: String?
): Single<Response<List<Account>>>
): Single<Response<List<TimelineAccount>>>
@POST("api/v1/follow_requests/{id}/authorize")
fun authorizeFollowRequest(
@ -440,24 +441,24 @@ interface MastodonApi {
@FormUrlEncoded
@POST("api/v1/apps")
fun authenticateApp(
suspend fun authenticateApp(
@Header(DOMAIN_HEADER) domain: String,
@Field("client_name") clientName: String,
@Field("redirect_uris") redirectUris: String,
@Field("scopes") scopes: String,
@Field("website") website: String
): Call<AppCredentials>
): AppCredentials
@FormUrlEncoded
@POST("oauth/token")
fun fetchOAuthToken(
suspend fun fetchOAuthToken(
@Header(DOMAIN_HEADER) domain: String,
@Field("client_id") clientId: String,
@Field("client_secret") clientSecret: String,
@Field("redirect_uri") redirectUri: String,
@Field("code") code: String,
@Field("grant_type") grantType: String
): Call<AccessToken>
): AccessToken
@FormUrlEncoded
@POST("api/v1/lists")
@ -481,7 +482,7 @@ interface MastodonApi {
fun getAccountsInList(
@Path("listId") listId: String,
@Query("limit") limit: Int
): Single<List<Account>>
): Single<List<TimelineAccount>>
@FormUrlEncoded
// @DELETE doesn't support fields

View File

@ -18,19 +18,19 @@ package com.keylesspalace.tusky.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat
import com.keylesspalace.tusky.BuildConfig
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.db.AccountManager
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.service.SendTootService
import com.keylesspalace.tusky.service.TootToSend
import com.keylesspalace.tusky.service.SendStatusService
import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.randomAlphanumericString
import dagger.android.AndroidInjection
import javax.inject.Inject
@ -45,22 +45,19 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
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) {
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)
@ -85,9 +82,9 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
} else {
val text = mentions.joinToString(" ", postfix = " ") { "@$it" } + message.toString()
val sendIntent = SendTootService.sendTootIntent(
val sendIntent = SendStatusService.sendStatusIntent(
context,
TootToSend(
StatusToSend(
text = text,
warningText = spoiler,
visibility = visibility.serverString(),
@ -110,14 +107,20 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
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)
.setSmallIcon(R.drawable.ic_notify)
.setColor(ContextCompat.getColor(context, (R.color.tusky_blue)))
.setColor(color)
.setGroup(senderFullName)
.setDefaults(0) // So it doesn't ring twice, notify only in Target callback
builder.setContentTitle(context.getString(R.string.status_sent))
builder.setContentText(context.getString(R.string.status_sent_long))
builder.setContentTitle(context.getString(R.string.post_sent))
builder.setContentText(context.getString(R.string.post_sent_long))
builder.setSubText(senderFullName)
builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
@ -126,29 +129,6 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
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.StatusScheduledEvent
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.AppDatabase
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.NewStatus
@ -30,18 +30,17 @@ import dagger.android.AndroidInjection
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.util.Timer
import java.util.TimerTask
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class SendTootService : Service(), Injectable {
class SendStatusService : Service(), Injectable {
@Inject
lateinit var mastodonApi: MastodonApi
@ -50,18 +49,14 @@ class SendTootService : Service(), Injectable {
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var database: AppDatabase
@Inject
lateinit var draftHelper: DraftHelper
private val supervisorJob = 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 timer = Timer()
private val notificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager }
override fun onCreate() {
@ -75,38 +70,38 @@ class SendTootService : Service(), Injectable {
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
if (intent.hasExtra(KEY_TOOT)) {
val tootToSend = intent.getParcelableExtra<TootToSend>(KEY_TOOT)
?: throw IllegalStateException("SendTootService started without $KEY_TOOT extra")
if (intent.hasExtra(KEY_STATUS)) {
val statusToSend = intent.getParcelableExtra<StatusToSend>(KEY_STATUS)
?: throw IllegalStateException("SendStatusService started without $KEY_STATUS extra")
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)
}
var notificationText = tootToSend.warningText
var notificationText = statusToSend.warningText
if (notificationText.isBlank()) {
notificationText = tootToSend.text
notificationText = statusToSend.text
}
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_toot_notification_title))
.setContentTitle(getString(R.string.send_post_notification_title))
.setContentText(notificationText)
.setProgress(1, 0, 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))
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)
startForeground(sendingNotificationId, builder.build())
} else {
notificationManager.notify(sendingNotificationId, builder.build())
}
tootsToSend[sendingNotificationId] = tootToSend
sendToot(sendingNotificationId--)
statusesToSend[sendingNotificationId] = statusToSend
sendStatus(sendingNotificationId--)
} else {
if (intent.hasExtra(KEY_CANCEL)) {
@ -117,96 +112,96 @@ class SendTootService : Service(), Injectable {
return START_NOT_STICKY
}
private fun sendToot(tootId: Int) {
private fun sendStatus(statusId: Int) {
// when tootToSend == null, sending has been canceled
val tootToSend = tootsToSend[tootId] ?: return
// when statusToSend == null, sending has been canceled
val statusToSend = statusesToSend[statusId] ?: return
// when account == null, user has logged out, cancel sending
val account = accountManager.getAccountById(tootToSend.accountId)
val account = accountManager.getAccountById(statusToSend.accountId)
if (account == null) {
tootsToSend.remove(tootId)
notificationManager.cancel(tootId)
statusesToSend.remove(statusId)
notificationManager.cancel(statusId)
stopSelfWhenDone()
return
}
tootToSend.retries++
statusToSend.retries++
val newStatus = NewStatus(
tootToSend.text,
tootToSend.warningText,
tootToSend.inReplyToId,
tootToSend.visibility,
tootToSend.sensitive,
tootToSend.mediaIds,
tootToSend.scheduledAt,
tootToSend.poll,
tootToSend.quoteId,
statusToSend.text,
statusToSend.warningText,
statusToSend.inReplyToId,
statusToSend.visibility,
statusToSend.sensitive,
statusToSend.mediaIds,
statusToSend.scheduledAt,
statusToSend.poll,
statusToSend.quoteId,
)
val sendCall = mastodonApi.createStatus(
"Bearer " + account.accessToken,
account.domain,
tootToSend.idempotencyKey,
statusToSend.idempotencyKey,
newStatus
)
sendCalls[tootId] = sendCall
sendCalls[statusId] = sendCall
val callback = object : Callback<Status> {
override fun onResponse(call: Call<Status>, response: Response<Status>) {
serviceScope.launch {
val scheduled = !tootToSend.scheduledAt.isNullOrEmpty()
tootsToSend.remove(tootId)
val scheduled = !statusToSend.scheduledAt.isNullOrEmpty()
statusesToSend.remove(statusId)
if (response.isSuccessful) {
// If the status was loaded from a draft, delete the draft and associated media files.
if (tootToSend.draftId != 0) {
serviceScope.launch {
draftHelper.deleteDraftAndAttachments(tootToSend.draftId)
if (response.isSuccessful) {
// If the status was loaded from a draft, delete the draft and associated media files.
if (statusToSend.draftId != 0) {
draftHelper.deleteDraftAndAttachments(statusToSend.draftId)
}
}
if (scheduled) {
response.body()?.let(::StatusScheduledEvent)?.let(eventHub::dispatch)
if (scheduled) {
response.body()?.let(::StatusScheduledEvent)?.let(eventHub::dispatch)
} else {
response.body()?.let(::StatusComposedEvent)?.let(eventHub::dispatch)
}
notificationManager.cancel(statusId)
} 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())
}
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()
}
stopSelfWhenDone()
}
override fun onFailure(call: Call<Status>, t: Throwable) {
var backoff = TimeUnit.SECONDS.toMillis(tootToSend.retries.toLong())
if (backoff > MAX_RETRY_INTERVAL) {
backoff = MAX_RETRY_INTERVAL
}
serviceScope.launch {
var backoff = TimeUnit.SECONDS.toMillis(statusToSend.retries.toLong())
if (backoff > MAX_RETRY_INTERVAL) {
backoff = MAX_RETRY_INTERVAL
}
timer.schedule(
object : TimerTask() {
override fun run() {
sendToot(tootId)
}
},
backoff
)
delay(backoff)
sendStatus(statusId)
}
}
}
@ -215,65 +210,52 @@ class SendTootService : Service(), Injectable {
private fun stopSelfWhenDone() {
if (tootsToSend.isEmpty()) {
ServiceCompat.stopForeground(this@SendTootService, ServiceCompat.STOP_FOREGROUND_REMOVE)
if (statusesToSend.isEmpty()) {
ServiceCompat.stopForeground(this@SendStatusService, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
private fun cancelSending(tootId: Int) {
val tootToCancel = tootsToSend.remove(tootId)
if (tootToCancel != null) {
val sendCall = sendCalls.remove(tootId)
private fun cancelSending(statusId: Int) = serviceScope.launch {
val statusToCancel = statusesToSend.remove(statusId)
if (statusToCancel != null) {
val sendCall = sendCalls.remove(statusId)
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)
.setContentTitle(getString(R.string.send_toot_notification_cancel_title))
.setContentText(getString(R.string.send_toot_notification_saved_content))
.setColor(ContextCompat.getColor(this@SendTootService, R.color.tusky_blue))
.setContentTitle(getString(R.string.send_post_notification_cancel_title))
.setContentText(getString(R.string.send_post_notification_saved_content))
.setColor(ContextCompat.getColor(this@SendStatusService, R.color.notification_color))
notificationManager.notify(tootId, builder.build())
notificationManager.notify(statusId, builder.build())
timer.schedule(
object : TimerTask() {
override fun run() {
notificationManager.cancel(tootId)
stopSelfWhenDone()
}
},
5000
)
delay(5000)
}
}
private fun saveTootToDrafts(toot: TootToSend) {
serviceScope.launch {
draftHelper.saveDraft(
draftId = toot.draftId,
accountId = toot.accountId,
inReplyToId = toot.inReplyToId,
content = toot.text,
contentWarning = toot.warningText,
sensitive = toot.sensitive,
visibility = Status.Visibility.byString(toot.visibility),
mediaUris = toot.mediaUris,
mediaDescriptions = toot.mediaDescriptions,
poll = toot.poll,
failedToSend = true
)
}
private suspend fun saveStatusToDrafts(status: StatusToSend) {
draftHelper.saveDraft(
draftId = status.draftId,
accountId = status.accountId,
inReplyToId = status.inReplyToId,
content = status.text,
contentWarning = status.warningText,
sensitive = status.sensitive,
visibility = Status.Visibility.byString(status.visibility),
mediaUris = status.mediaUris,
mediaDescriptions = status.mediaDescriptions,
poll = status.poll,
failedToSend = true
)
}
private fun cancelSendingIntent(tootId: Int): PendingIntent {
val intent = Intent(this, SendTootService::class.java)
intent.putExtra(KEY_CANCEL, tootId)
return PendingIntent.getService(this, tootId, intent, PendingIntent.FLAG_UPDATE_CURRENT)
private fun cancelSendingIntent(statusId: Int): PendingIntent {
val intent = Intent(this, SendStatusService::class.java)
intent.putExtra(KEY_CANCEL, statusId)
return PendingIntent.getService(this, statusId, intent, NotificationHelper.pendingIntentFlags(false))
}
override fun onDestroy() {
@ -283,7 +265,7 @@ class SendTootService : Service(), Injectable {
companion object {
private const val KEY_TOOT = "toot"
private const val KEY_STATUS = "status"
private const val KEY_CANCEL = "cancel_id"
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
@JvmStatic
fun sendTootIntent(
fun sendStatusIntent(
context: Context,
tootToSend: TootToSend
statusToSend: StatusToSend
): Intent {
val intent = Intent(context, SendTootService::class.java)
intent.putExtra(KEY_TOOT, tootToSend)
val intent = Intent(context, SendStatusService::class.java)
intent.putExtra(KEY_STATUS, statusToSend)
if (tootToSend.mediaUris.isNotEmpty()) {
if (statusToSend.mediaUris.isNotEmpty()) {
// forward uri permissions
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val uriClip = ClipData(
ClipDescription("Toot Media", arrayOf("image/*", "video/*")),
ClipData.Item(tootToSend.mediaUris[0])
ClipDescription("Status Media", arrayOf("image/*", "video/*")),
ClipData.Item(statusToSend.mediaUris[0])
)
tootToSend.mediaUris
statusToSend.mediaUris
.drop(1)
.forEach { mediaUri ->
uriClip.addItem(ClipData.Item(mediaUri))
@ -322,7 +304,7 @@ class SendTootService : Service(), Injectable {
}
@Parcelize
data class TootToSend(
data class StatusToSend(
val text: String,
val warningText: String,
val visibility: String,

View File

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

View File

@ -280,7 +280,7 @@ class EmojiCompatFont(
R.string.caption_blobmoji,
R.drawable.ic_blobmoji,
"https://tusky.app/hosted/emoji/BlobmojiCompat.ttf",
"12.0.0"
"14.0.1"
)
val TWEMOJI = EmojiCompatFont(
"Twemoji",
@ -288,7 +288,7 @@ class EmojiCompatFont(
R.string.caption_twemoji,
R.drawable.ic_twemoji,
"https://tusky.app/hosted/emoji/TwemojiCompat.ttf",
"12.0.0"
"14.0.0"
)
val NOTOEMOJI = EmojiCompatFont(
"NotoEmoji",
@ -296,7 +296,7 @@ class EmojiCompatFont(
R.string.caption_notoemoji,
R.drawable.ic_notoemoji,
"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) {
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)
if (firstMention) {
firstMention = false
@ -160,7 +160,7 @@ fun setClickableMentions(view: TextView, mentions: List<Mention>?, listener: Lin
}
append("@")
append(mention.username)
append(mention.localUsername)
setSpan(customSpan, start, end, flags)
append("\u200B") // same reasoning as in setClickableText
end += 1 // shift position to take the previous character into account

View File

@ -270,12 +270,12 @@ class ListStatusAccessibilityDelegate(
private val collapseCwAction = AccessibilityActionCompat(
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(
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(

View File

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

View File

@ -16,51 +16,6 @@ fun randomAlphanumericString(count: Int): String {
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.
* 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 {
var i = length
do {

View File

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

View File

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

View File

@ -17,7 +17,7 @@
package com.keylesspalace.tusky.viewmodel
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.util.Either
import com.keylesspalace.tusky.util.Either.Left
@ -28,7 +28,7 @@ import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.subjects.BehaviorSubject
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() {
@ -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))
.subscribe(
{

View File

@ -63,7 +63,7 @@ class QuickTootViewModel @Inject constructor(
fun composeOptions(tootRightNow: Boolean): ComposeActivity.ComposeOptions {
return ComposeActivity.ComposeOptions(
tootText = content.value,
content = content.value,
mentionedUsernames = inReplyTo.value
?.let {
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.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.HashTag;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
@ -23,21 +23,21 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions;
import java.util.List;
public class QuoteInlineHelper {
private Status quoteStatus;
private final Status quoteStatus;
private View quoteContainer;
private ImageView quoteAvatar;
private TextView quoteDisplayName;
private TextView quoteUsername;
private TextView quoteContentWarningDescription;
private MaterialButton quoteContentWarningButton;
private TextView quoteContent;
private TextView quoteMedia;
private final View quoteContainer;
private final ImageView quoteAvatar;
private final TextView quoteDisplayName;
private final TextView quoteUsername;
private final TextView quoteContentWarningDescription;
private final MaterialButton quoteContentWarningButton;
private final TextView quoteContent;
private final TextView quoteMedia;
private LinkListener listener;
private final LinkListener listener;
@Px
private int avatarRadius24dp;
private StatusDisplayOptions statusDisplayOptions;
private final int avatarRadius24dp;
private final StatusDisplayOptions statusDisplayOptions;
public QuoteInlineHelper(Status status, View container, LinkListener listener,
@Px int avatarRadius24dp, StatusDisplayOptions statusDisplayOptions) {
@ -62,7 +62,7 @@ public class QuoteInlineHelper {
private void setUsername(String name) {
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);
quoteUsername.setText(usernameText);
}
@ -97,10 +97,10 @@ public class QuoteInlineHelper {
private void setContentVisibility(boolean show) {
if (show) {
quoteContent.setVisibility(View.VISIBLE);
quoteContentWarningButton.setText(R.string.status_content_warning_show_less);
quoteContentWarningButton.setText(R.string.post_content_warning_show_less);
} else {
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() {
Account account = quoteStatus.getAccount();
TimelineAccount account = quoteStatus.getAccount();
setDisplayName(account.getName(), account.getEmojis());
setUsername(account.getUsername());
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_height="wrap_content"
android:paddingBottom="6dp"
android:text="@string/title_statuses"
android:text="@string/title_posts"
android:textColor="@color/account_tab_font_color"
android:textSize="?attr/status_text_medium" />

View File

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

View File

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

View File

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

View File

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

View File

@ -19,7 +19,7 @@
android:drawablePadding="4dp"
android:fontFamily="sans-serif-medium"
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:textSize="?attr/status_text_medium"
app:drawableStartCompat="@drawable/ic_alert_circle"
@ -53,7 +53,7 @@
app:layout_constraintEnd_toStartOf="@id/deleteButton"
app:layout_constraintStart_toStartOf="parent"
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
android:id="@+id/deleteButton"

View File

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

View File

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

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