mirror of
https://github.com/accelforce/Yuito
synced 2024-12-22 05:19:05 +01:00
Merge remote-tracking branch 'tuskyapp/develop'
This commit is contained in:
commit
1228f645a6
@ -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/
|
||||
|
||||
|
@ -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"
|
||||
|
809
app/schemas/com.keylesspalace.tusky.db.AppDatabase/31.json
Normal file
809
app/schemas/com.keylesspalace.tusky.db.AppDatabase/31.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
6
app/src/green/res/values/flavor-colors.xml
Normal file
6
app/src/green/res/values/flavor-colors.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<color name="notification_color">#19A341</color>
|
||||
|
||||
</resources>
|
@ -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="merge">
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
|
||||
<activity android:name="net.accelf.yuito.AccessTokenLoginActivity" />
|
||||
</application>
|
||||
|
@ -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 {
|
||||
private object AccountDiffer : DiffUtil.ItemCallback<TimelineAccount>() {
|
||||
override fun areItemsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: Account, newItem: Account): Boolean {
|
||||
return oldItem.deepEquals(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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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 "";
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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,7 +263,7 @@ 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()
|
||||
@ -272,7 +286,6 @@ class ComposeActivity :
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupReplyViews(replyingStatusAuthor: String?, replyingStatusContent: String?) {
|
||||
if (replyingStatusAuthor != null) {
|
||||
@ -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,
|
||||
{
|
||||
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 -> {
|
||||
@ -1101,7 +1104,7 @@ class ComposeActivity :
|
||||
// Let's keep fields var until all consumers are Kotlin
|
||||
var scheduledTootId: String? = null,
|
||||
var draftId: Int? = null,
|
||||
var tootText: String? = null,
|
||||
var content: String? = null,
|
||||
var mediaUrls: List<String>? = null,
|
||||
var mediaDescriptions: List<String>? = null,
|
||||
var mentionedUsernames: Set<String>? = null,
|
||||
@ -1120,7 +1123,7 @@ class ComposeActivity :
|
||||
var sensitive: Boolean? = null,
|
||||
var poll: NewPoll? = null,
|
||||
var modifiedInitialState: Boolean? = null,
|
||||
var tootRightNow: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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,7 +315,7 @@ class ComposeViewModel @Inject constructor(
|
||||
mediaDescriptions.add(item.description ?: "")
|
||||
}
|
||||
|
||||
val tootToSend = TootToSend(
|
||||
val tootToSend = StatusToSend(
|
||||
text = content,
|
||||
warningText = spoilerText,
|
||||
visibility = statusVisibility.value!!.serverString(),
|
||||
@ -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) {
|
||||
|
@ -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,13 +85,18 @@ 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)
|
||||
var mimeType: String? = null
|
||||
|
||||
try {
|
||||
when (inUri.scheme) {
|
||||
ContentResolver.SCHEME_CONTENT -> {
|
||||
|
||||
mimeType = contentResolver.getType(uri)
|
||||
|
||||
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
|
||||
|
||||
try {
|
||||
contentResolver.openInputStream(inUri).use { input ->
|
||||
if (input == null) {
|
||||
Log.w(TAG, "Media input is null")
|
||||
@ -107,12 +114,41 @@ class MediaUploader @Inject constructor(
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
@ -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!!
|
||||
),
|
||||
return emojiEditTextHelper.onCreateInputConnection(
|
||||
InputConnectionCompat.createWrapper(this, connection, editorInfo),
|
||||
editorInfo
|
||||
)!!
|
||||
} else {
|
||||
connection
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEmojiEditTextHelper(): EmojiEditTextHelper {
|
||||
return emojiEditTextHelper
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -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() {
|
||||
|
@ -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 <= ' ' }
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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 -> {
|
||||
|
@ -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,7 +357,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
||||
}
|
||||
|
||||
private fun updateHttpProxySummary() {
|
||||
val sharedPreferences = preferenceManager.sharedPreferences
|
||||
preferenceManager.sharedPreferences?.let { sharedPreferences ->
|
||||
val httpProxyEnabled = sharedPreferences.getBoolean(PrefKeys.HTTP_PROXY_ENABLED, false)
|
||||
val httpServer = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_SERVER, "")
|
||||
|
||||
@ -368,6 +375,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
||||
|
||||
httpProxyPref?.summary = ""
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance(): PreferencesFragment {
|
||||
|
@ -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 {
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
@ -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)
|
||||
}
|
||||
}
|
@ -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),
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 response = db.withTransaction {
|
||||
val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId)
|
||||
val nextPlaceholderId = timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId)
|
||||
|
||||
val response = api.homeTimeline(maxId = placeholderId.inc(), sinceId = nextPlaceholderId, limit = LOAD_AT_ONCE).await()
|
||||
api.homeTimeline(maxId = idAbovePlaceholder, sinceId = nextPlaceholderId, limit = LOAD_AT_ONCE)
|
||||
}.await()
|
||||
|
||||
val statuses = response.body()
|
||||
if (!response.isSuccessful || statuses == null) {
|
||||
@ -187,17 +188,24 @@ 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) {
|
||||
ifExpected(e) {
|
||||
loadMoreFailed(placeholderId, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadMoreFailed(placeholderId: String, e: Exception) {
|
||||
Log.w("CachedTimelineVM", "failed loading statuses", 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))
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,10 +200,12 @@ class NetworkTimelineViewModel @Inject constructor(
|
||||
|
||||
currentSource?.invalidate()
|
||||
} catch (e: Exception) {
|
||||
ifExpected(e) {
|
||||
loadMoreFailed(placeholderId, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadMoreFailed(placeholderId: String, e: Exception) {
|
||||
Log.w("NetworkTimelineVM", "failed loading statuses", 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,
|
||||
))
|
||||
}
|
||||
|
||||
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?,
|
||||
|
@ -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
|
||||
|
@ -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`");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -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?
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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?
|
||||
) {
|
||||
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
@ -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)
|
||||
|
@ -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());
|
||||
|
@ -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 ?: "")
|
||||
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> ")
|
||||
?.replace("<br /> ", "<br /> ")
|
||||
?.replace("<br/> ", "<br/> ")
|
||||
?.replace(" ", " ")
|
||||
?.let { html ->
|
||||
Jsoup.parse(html)
|
||||
.apply {
|
||||
select(".quote-inline").forEach { it.remove() }
|
||||
}
|
||||
.html().parseAsHtml().trimTrailingWhitespace()
|
||||
.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))
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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,6 +45,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
AndroidInjection.inject(this, 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)
|
||||
@ -53,15 +54,11 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
|
||||
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 message = getReplyMessage(intent)
|
||||
|
||||
if (account == null) {
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,56 +112,55 @@ 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 (statusToSend.draftId != 0) {
|
||||
draftHelper.deleteDraftAndAttachments(statusToSend.draftId)
|
||||
}
|
||||
|
||||
if (scheduled) {
|
||||
@ -175,38 +169,39 @@ class SendTootService : Service(), Injectable {
|
||||
response.body()?.let(::StatusComposedEvent)?.let(eventHub::dispatch)
|
||||
}
|
||||
|
||||
notificationManager.cancel(tootId)
|
||||
notificationManager.cancel(statusId)
|
||||
} else {
|
||||
// the server refused to accept the toot, save toot & show error message
|
||||
saveTootToDrafts(tootToSend)
|
||||
// the server refused to accept the status, save status & show error message
|
||||
saveStatusToDrafts(statusToSend)
|
||||
|
||||
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_error_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_error_title))
|
||||
.setContentText(getString(R.string.send_post_notification_saved_content))
|
||||
.setColor(
|
||||
ContextCompat.getColor(
|
||||
this@SendStatusService,
|
||||
R.color.notification_color
|
||||
)
|
||||
)
|
||||
|
||||
notificationManager.cancel(tootId)
|
||||
notificationManager.cancel(statusId)
|
||||
notificationManager.notify(errorNotificationId--, builder.build())
|
||||
}
|
||||
|
||||
stopSelfWhenDone()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<Status>, t: Throwable) {
|
||||
var backoff = TimeUnit.SECONDS.toMillis(tootToSend.retries.toLong())
|
||||
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)
|
||||
delay(backoff)
|
||||
sendStatus(statusId)
|
||||
}
|
||||
},
|
||||
backoff
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
private suspend fun saveStatusToDrafts(status: StatusToSend) {
|
||||
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,
|
||||
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,
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
)
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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(
|
||||
{
|
||||
|
@ -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()))
|
||||
|
@ -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 |
@ -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>
|
@ -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" />
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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"
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user