Merge tag 'v17.0' into develop

This commit is contained in:
Mike Barnes 2022-06-06 16:57:59 +10:00
commit ea95fc2f4b
243 changed files with 8456 additions and 5710 deletions

View file

@ -14,7 +14,7 @@ It is derived from [Tusky](https://tusky.app) and has been modified to reflect C
- Material Design
- Multi-Account support
- Respect device preferences for light/dark theming
- 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
@ -22,7 +22,7 @@ It is derived from [Tusky](https://tusky.app) and has been modified to reflect C
### Support
Check out Tusky's [FAQs](https://github.com/tuskyapp/faq), your question may already be answered.
If you have any bug reports, feature requests or questions please open an issue or send us a toot at [chinwagnews@chinwag.org](https://social.chinwag.org/@ChinwagNews)!
If you have any bug reports, feature requests or questions please open an issue or send us a message at [chinwagnews@chinwag.org](https://social.chinwag.org/@ChinwagNews)!
For translating Tusky into your language, visit https://weblate.tusky.app/

View file

@ -15,13 +15,13 @@ def getGitSha = {
}
android {
compileSdkVersion 30
compileSdkVersion 31
defaultConfig {
applicationId APP_ID
minSdkVersion 21
targetSdkVersion 30
targetSdkVersion 31
versionCode 87
versionName "16.0-CW1"
versionName "17.0-CW1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@ -90,12 +90,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'
// if libraries are changed here, they should also be changed in LicenseActivity
@ -105,33 +105,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"
@ -146,14 +148,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"
@ -169,7 +171,7 @@ dependencies {
implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion"
implementation 'com.mikepenz:google-material-typeface:4.0.0.2-kotlin@aar'
implementation "com.github.CanHub:Android-Image-Cropper:3.1.0"
implementation "com.github.CanHub:Android-Image-Cropper:4.1.0"
implementation "de.c1710:filemojicompat:1.0.18"

View file

@ -0,0 +1,789 @@
{
"formatVersion": 1,
"database": {
"version": 29,
"identityHash": "62c289344334da2db091ad4ba0a49c6a",
"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"
],
"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, `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": "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"
],
"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, '62c289344334da2db091ad4ba0a49c6a')"
]
}
}

View file

@ -0,0 +1,807 @@
{
"formatVersion": 1,
"database": {
"version": 30,
"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"
],
"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"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
}
],
"foreignKeys": [
{
"table": "TimelineAccountEntity",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"authorServerId",
"timelineUserId"
],
"referencedColumns": [
"serverId",
"timelineUserId"
]
}
]
},
{
"tableName": "TimelineAccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localUsername",
"columnName": "localUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bot",
"columnName": "bot",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ConversationEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accounts",
"columnName": "accounts",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.id",
"columnName": "s_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.url",
"columnName": "s_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToId",
"columnName": "s_inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToAccountId",
"columnName": "s_inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.account",
"columnName": "s_account",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.content",
"columnName": "s_content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.createdAt",
"columnName": "s_createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.emojis",
"columnName": "s_emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.favouritesCount",
"columnName": "s_favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.favourited",
"columnName": "s_favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.bookmarked",
"columnName": "s_bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.sensitive",
"columnName": "s_sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.spoilerText",
"columnName": "s_spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.attachments",
"columnName": "s_attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.mentions",
"columnName": "s_mentions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.tags",
"columnName": "s_tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsible",
"columnName": "s_collapsible",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsed",
"columnName": "s_collapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.muted",
"columnName": "s_muted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.poll",
"columnName": "s_poll",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id",
"accountId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a75615171612bdfc9e3d4201ebf6071a')"
]
}
}

View file

@ -0,0 +1,809 @@
{
"formatVersion": 1,
"database": {
"version": 31,
"identityHash": "a75615171612bdfc9e3d4201ebf6071a",
"entities": [
{
"tableName": "DraftEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentWarning",
"columnName": "contentWarning",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "failedToSend",
"columnName": "failedToSend",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "AccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "domain",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profilePictureUrl",
"columnName": "profilePictureUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsMentioned",
"columnName": "notificationsMentioned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowed",
"columnName": "notificationsFollowed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowRequested",
"columnName": "notificationsFollowRequested",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsReblogged",
"columnName": "notificationsReblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFavorited",
"columnName": "notificationsFavorited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsPolls",
"columnName": "notificationsPolls",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsSubscriptions",
"columnName": "notificationsSubscriptions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationSound",
"columnName": "notificationSound",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationVibration",
"columnName": "notificationVibration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationLight",
"columnName": "notificationLight",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultPostPrivacy",
"columnName": "defaultPostPrivacy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultMediaSensitivity",
"columnName": "defaultMediaSensitivity",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysShowSensitiveMedia",
"columnName": "alwaysShowSensitiveMedia",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysOpenSpoiler",
"columnName": "alwaysOpenSpoiler",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mediaPreviewEnabled",
"columnName": "mediaPreviewEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastNotificationId",
"columnName": "lastNotificationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activeNotifications",
"columnName": "activeNotifications",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tabPreferences",
"columnName": "tabPreferences",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsFilter",
"columnName": "notificationsFilter",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_AccountEntity_domain_accountId",
"unique": true,
"columnNames": [
"domain",
"accountId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)"
}
],
"foreignKeys": []
},
{
"tableName": "InstanceEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))",
"fields": [
{
"fieldPath": "instance",
"columnName": "instance",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojiList",
"columnName": "emojiList",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "maximumTootCharacters",
"columnName": "maximumTootCharacters",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptions",
"columnName": "maxPollOptions",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptionLength",
"columnName": "maxPollOptionLength",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "minPollDuration",
"columnName": "minPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollDuration",
"columnName": "maxPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "charactersReservedPerUrl",
"columnName": "charactersReservedPerUrl",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"instance"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TimelineStatusEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authorServerId",
"columnName": "authorServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToAccountId",
"columnName": "inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogsCount",
"columnName": "reblogsCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favouritesCount",
"columnName": "favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "spoilerText",
"columnName": "spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogServerId",
"columnName": "reblogServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogAccountId",
"columnName": "reblogAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "expanded",
"columnName": "expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentCollapsed",
"columnName": "contentCollapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentShowing",
"columnName": "contentShowing",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
"unique": false,
"columnNames": [
"authorServerId",
"timelineUserId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
}
],
"foreignKeys": [
{
"table": "TimelineAccountEntity",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"authorServerId",
"timelineUserId"
],
"referencedColumns": [
"serverId",
"timelineUserId"
]
}
]
},
{
"tableName": "TimelineAccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localUsername",
"columnName": "localUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bot",
"columnName": "bot",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ConversationEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accounts",
"columnName": "accounts",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.id",
"columnName": "s_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.url",
"columnName": "s_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToId",
"columnName": "s_inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToAccountId",
"columnName": "s_inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.account",
"columnName": "s_account",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.content",
"columnName": "s_content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.createdAt",
"columnName": "s_createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.emojis",
"columnName": "s_emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.favouritesCount",
"columnName": "s_favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.favourited",
"columnName": "s_favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.bookmarked",
"columnName": "s_bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.sensitive",
"columnName": "s_sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.spoilerText",
"columnName": "s_spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.attachments",
"columnName": "s_attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.mentions",
"columnName": "s_mentions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.tags",
"columnName": "s_tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsible",
"columnName": "s_collapsible",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsed",
"columnName": "s_collapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.muted",
"columnName": "s_muted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.poll",
"columnName": "s_poll",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id",
"accountId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a75615171612bdfc9e3d4201ebf6071a')"
]
}
}

View file

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

View file

@ -20,9 +20,12 @@
android:supportsRtl="true"
android:theme="@style/TuskyTheme"
android:usesCleartextTraffic="false">
<activity
android:name=".SplashActivity"
android:theme="@style/SplashTheme">
android:theme="@style/SplashTheme"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -35,22 +38,15 @@
</activity>
<activity
android:name=".LoginActivity"
android:name=".components.login.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=".components.login.LoginWebViewActivity" />
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize">
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.SEND" />
@ -106,7 +102,6 @@
<activity
android:name=".ViewThreadActivity"
android:configChanges="orientation|screenSize" />
<activity android:name=".ViewTagActivity" />
<activity
android:name=".ViewMediaActivity"
android:theme="@style/TuskyBaseTheme" />
@ -124,7 +119,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>
@ -134,18 +130,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"
@ -154,15 +150,17 @@
<service
android:name=".service.TuskyTileService"
android:icon="@drawable/ic_chinwag_logo_simple"
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"
@ -176,10 +174,16 @@
<!-- disable automatic WorkManager initialization -->
<provider
android:name="androidx.work.impl.WorkManagerInitializer"
android:authorities="${applicationId}.workmanager-init"
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="remove" />
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
</manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View file

@ -1,488 +1,101 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
id="svg181"
viewBox="0 0 135.46666 135.46666"
version="1.1"
height="512"
width="512"
sodipodi:docname="ic_launcher2.svg"
inkscape:version="0.92.1 r15371">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1147"
id="namedview4579"
showgrid="false"
inkscape:measure-start="140.54,184.424"
inkscape:measure-end="499.461,-175.02"
inkscape:zoom="1.9140625"
inkscape:cx="253.80846"
inkscape:cy="248.95369"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="g157" />
<metadata
id="metadata185">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs147">
<linearGradient
inkscape:collect="always"
id="linearGradient4780">
<stop
style="stop-color:#2588d0;stop-opacity:1;"
offset="0"
id="stop4776" />
<stop
style="stop-color:#1967a3;stop-opacity:1"
offset="1"
id="stop4778" />
</linearGradient>
<linearGradient
id="c">
<stop
id="stop2"
offset="0" />
<stop
id="stop4"
offset="1"
stop-opacity="0" />
</linearGradient>
<clipPath
id="g">
<circle
id="circle7"
fill="#2588d0"
r="112.52"
cy="125"
cx="125" />
</clipPath>
<clipPath
id="e">
<g
id="g132"
opacity=".05"
fill="url(#a)"
clip-path="url(#g)"
transform="matrix(.26458 0 0 .26458 7.7244 -29.561)">
<g
id="g128"
stroke-width=".5">
<path
id="path10"
fill="url(#a)"
d="m98.535 73.473c-1.8371-0.09054-2.2298 0.10241-1.635 0.69722l70.711 70.711c-0.59481-0.59481-0.2021-0.78775 1.635-0.69722z" />
<path
id="path12"
fill="url(#a)"
d="m96.9 74.17c0.70818 0.70818 2.8162 1.986 5.553 4.0332l70.711 70.711c-2.7368-2.0472-4.8448-3.3251-5.553-4.0332z" />
<path
id="path14"
fill="url(#a)"
d="m102.45 78.203c2.4807 1.8557 4.9237 4.0232 7.1498 6.2492l70.711 70.711c-2.226-2.226-4.6691-4.3935-7.1498-6.2492z" />
<path
id="path16"
fill="url(#a)"
d="m109.6 84.452c3.2825 3.2825 6.093 6.6922 7.8561 9.4168l70.711 70.711c-1.7631-2.7246-4.5736-6.1343-7.8561-9.4168z" />
<path
id="path18"
fill="url(#a)"
d="m117.46 93.869c2.9912 4.6225 5.6692 8.3037 12.262 28.254l70.711 70.711c-6.5925-19.95-9.2705-23.631-12.262-28.254z" />
<path
id="path20"
fill="url(#a)"
d="m129.72 122.12c2.4794 7.5032 5.7538 14.14 6.6211 16.113l70.711 70.711c-0.86733-1.9736-4.1417-8.6101-6.6211-16.113z" />
<path
id="path22"
fill="url(#a)"
d="m136.34 138.24 1.3476 3.0059 70.711 70.711-1.3476-3.0059z" />
<path
id="path24"
fill="url(#a)"
d="m137.69 141.24c-0.0653 0.0814-0.11927 0.14322-0.18554 0.22656l70.711 70.711c0.0663-0.0833 0.12024-0.14516 0.18554-0.22656z" />
<path
id="path26"
fill="url(#a)"
d="m137.5 141.47-3.4395 3.0098 70.711 70.711 3.4395-3.0098z" />
<path
id="path28"
fill="url(#a)"
d="m134.06 144.48c-5.5481 5.7637-16.033 11.691-24.285 13.729l70.711 70.711c8.2524-2.0372 18.737-7.9648 24.285-13.729z" />
<path
id="path30"
fill="url(#a)"
d="m109.78 158.21c-6.3614 1.5706-21.301 1.1651-27.809 0.0137l70.711 70.711c6.5077 1.1514 21.447 1.5569 27.809-0.0137z" />
<path
id="path32"
fill="url(#a)"
d="m81.971 158.22c-7.8527-1.3894-14.205-5.074-19.134-10.002l70.711 70.711c4.9282 4.9282 11.281 8.6128 19.134 10.002z" />
<path
id="path34"
fill="url(#a)"
d="m62.837 148.22c-6.1069-6.1069-10.026-14.123-11.902-22.049l70.711 70.711c1.8752 7.9251 5.7946 15.942 11.902 22.049z" />
<path
id="path36"
fill="url(#a)"
d="m50.936 126.17c-1.5987-9.5426 0.52116-16.959 1.6895-21.707l70.711 70.711c-1.1683 4.7477-3.2882 12.164-1.6894 21.707z" />
<path
id="path38"
fill="url(#a)"
d="m52.625 104.46c2.6637-10.825 9.7356-20.465 18.479-26.402l70.711 70.711c-8.7429 5.9372-15.815 15.578-18.479 26.402z" />
<path
id="path40"
fill="url(#a)"
d="m71.104 78.061c3.0009-2.0378 4.6792-2.7066 4.4597-2.9261l70.711 70.711c0.21947 0.21947-1.4588 0.88834-4.4597 2.9261z" />
<path
id="path42"
fill="url(#a)"
d="m75.563 75.134c-0.11026-0.11026-0.69947-0.10709-1.8406-0.10709l70.711 70.711c1.1411 0 1.7303-3e-3 1.8406 0.10709z" />
<path
id="path44"
fill="url(#a)"
d="m73.723 75.027c-3.9668 0-10.077 2.6389-14.275 6.0215l70.711 70.711c4.1987-3.3826 10.309-6.0215 14.275-6.0215z" />
<path
id="path46"
fill="url(#a)"
d="m59.447 81.049c-8.0199 6.461-14.768 15.598-18.961 26.336l70.711 70.711c4.1931-10.738 10.941-19.875 18.961-26.336z" />
<path
id="path48"
fill="url(#a)"
d="m40.486 107.38c-2.2433 6.6848-2.2747 5.5109-2.418 15.162l70.711 70.711c0.14324-9.6512 0.17463-8.4773 2.418-15.162z" />
<path
id="path50"
fill="url(#a)"
d="m38.068 122.55c0.37633 6.9829 1.0389 9.3071 3.0723 15.406l70.711 70.711c-2.0333-6.0991-2.6959-8.4234-3.0723-15.406z" />
<path
id="path52"
fill="url(#a)"
d="m41.141 137.95c2.3472 7.0404 6.2768 13.161 11.343 18.227l70.711 70.711c-5.0663-5.0663-8.9959-11.187-11.343-18.227z" />
<path
id="path54"
fill="url(#a)"
d="m52.484 156.18c2.9744 2.9744 6.3406 5.5855 10.008 7.806l70.711 70.711c-3.6678-2.2205-7.034-4.8316-10.008-7.806z" />
<path
id="path56"
fill="url(#a)"
d="m62.492 163.99c4.9985 3.0261 15.324 6.7023 22.098 7.8027l70.711 70.711c-6.7734-1.1005-17.099-4.7766-22.098-7.8027z" />
<path
id="path58"
fill="url(#a)"
d="m84.59 171.79c1.8904 0.30967 4.992 0.90865 7.8848 1.1875l70.711 70.711c-2.8928-0.27885-5.9944-0.87783-7.8848-1.1875z" />
<path
id="path60"
fill="url(#a)"
d="m92.475 172.98c12.195 0.94625 15.055-0.32666 24.506-1.8418l70.711 70.711c-9.4508 1.5151-12.311 2.788-24.506 1.8418z" />
<path
id="path62"
fill="url(#a)"
d="m116.98 171.13c8.5906-2.4983 16.678-7.275 22.934-12.367l70.711 70.711c-6.2561 5.0922-14.343 9.8689-22.934 12.367z" />
<path
id="path64"
fill="url(#a)"
d="m139.91 158.77 5.7871-4.7168 70.711 70.711-5.7871 4.7168z" />
<path
id="path66"
fill="url(#a)"
d="m145.7 154.05c0.0891 0.11275 0.1968 0.26762 0.28321 0.375l70.711 70.711c-0.0864-0.10738-0.19411-0.26225-0.28321-0.375z" />
<path
id="path68"
fill="url(#a)"
d="m145.98 154.43 1.9648 2.0644 70.711 70.711-1.9648-2.0644z" />
<path
id="path70"
fill="url(#a)"
d="m147.95 156.49c0.29948 0.31455 0.59902 0.62179 0.89884 0.92161l70.711 70.711c-0.29982-0.29982-0.59936-0.60705-0.89884-0.9216z" />
<path
id="path72"
fill="url(#a)"
d="m148.85 157.41c5.8411 5.8411 11.787 8.8672 19.377 8.303l70.711 70.711c-7.5898 0.56423-13.535-2.4619-19.377-8.303z" />
<path
id="path74"
fill="url(#a)"
d="m168.22 165.71c7.5551-0.56163 13.041-2.6761 17.49-6.7422l70.711 70.711c-4.4488 4.066-9.9351 6.1806-17.49 6.7422z" />
<path
id="path76"
fill="url(#a)"
d="m185.71 158.97c4.5132-4.1248 5.5354-6.236 7.6699-12.512l70.711 70.711c-2.1345 6.2758-3.1567 8.3869-7.6699 12.512z" />
<path
id="path78"
fill="url(#a)"
d="m193.38 146.46c2.9552-8.6889 4.3184-16.193 3.9922-29.721l70.711 70.711c0.32618 13.527-1.0369 21.032-3.9922 29.721z" />
<path
id="path80"
fill="url(#a)"
d="m197.38 116.74c-0.33294-13.818-1.6567-19.14-5.4434-23.453l70.711 70.711c3.7867 4.3128 5.1104 9.6355 5.4434 23.453z" />
<path
id="path82"
fill="url(#a)"
d="m191.93 93.287c-0.0959-0.10288-0.19218-0.20269-0.28888-0.2994l70.711 70.711c0.0967 0.0967 0.19301 0.19652 0.28888 0.2994z" />
<path
id="path84"
fill="url(#a)"
d="m191.64 92.988c-3.0781-3.0781-6.553-3.0044-8.5724 1.8287l70.711 70.711c2.0194-4.8331 5.4943-4.9068 8.5724-1.8287z" />
<path
id="path86"
fill="url(#a)"
d="m183.07 94.816c-0.79737 1.9089-0.78178 2.91 0.11132 6.918l70.711 70.711c-0.8931-4.008-0.90869-5.0091-0.11132-6.918z" />
<path
id="path88"
fill="url(#a)"
d="m183.18 101.73c1.6289 7.3102 1.7837 25.828 0.45313 32.072l70.711 70.711c1.3306-6.2445 1.1758-24.762-0.45313-32.072z" />
<path
id="path90"
fill="url(#a)"
d="m183.64 133.81c-2.7904 13.095-11.463 17.859-22.207 12.49l70.711 70.711c10.744 5.3688 19.417 0.60451 22.207-12.49z" />
<path
id="path92"
fill="url(#a)"
d="m161.43 146.3c-2.4274-1.213-3.8861-1.9048-5.748-3.5762l70.711 70.711c1.862 1.6714 3.3206 2.3632 5.748 3.5762z" />
<path
id="path94"
fill="url(#a)"
d="m155.68 142.72c-0.18578-0.19582-0.32768-0.35348-0.50391-0.54101l70.711 70.711c0.17623 0.18753 0.31813 0.34519 0.50391 0.54101z" />
<path
id="path96"
fill="url(#a)"
d="m155.18 142.18c1.047-1.7899 1.5781-3.0116 2.7754-4.9883l70.711 70.711c-1.1973 1.9767-1.7284 3.1984-2.7754 4.9883z" />
<path
id="path98"
fill="url(#a)"
d="m157.95 137.19c4.9715-8.2077 9.7196-21.436 11.604-32.795l70.711 70.711c-1.8839 11.359-6.632 24.587-11.604 32.795z" />
<path
id="path100"
fill="url(#a)"
d="m169.56 104.4c1.1273-6.797 0.81713-9.1729-0.83126-10.821l70.711 70.711c1.6484 1.6484 1.9586 4.0243 0.83126 10.821z" />
<path
id="path102"
fill="url(#a)"
d="m168.73 93.575c-0.32731-0.32731-0.70738-0.62594-1.1394-0.92479l70.711 70.711c0.43206 0.29886 0.81213 0.59748 1.1394 0.92479z" />
<path
id="path104"
fill="url(#a)"
d="m167.59 92.65c-2.0094-0.98322-2.8331-1.0491-4.9531-0.39844l70.711 70.711c2.12-0.65064 2.9438-0.58478 4.9531 0.39844z" />
<path
id="path106"
fill="url(#a)"
d="m162.63 92.252c-1.3988 0.42933-3.0221 1.3082-3.6055 1.9531l70.711 70.711c0.58337-0.64492 2.2067-1.5238 3.6055-1.9531z" />
<path
id="path108"
fill="url(#a)"
d="m159.03 94.205c-0.58363 0.64492-1.9734 4.6467-3.0879 8.8945l70.711 70.711c1.1145-4.2479 2.5043-8.2496 3.0879-8.8945z" />
<path
id="path110"
fill="url(#a)"
d="m155.94 103.1c-1.1144 4.2479-2.7555 9.2858-3.6465 11.193l70.711 70.711c0.89102-1.9076 2.532-6.9455 3.6465-11.193z" />
<path
id="path112"
fill="url(#a)"
d="m152.29 114.29c-0.89099 1.9076-1.8493 4.2376-2.1309 5.1777l70.711 70.711c0.28157-0.94013 1.2399-3.2702 2.1309-5.1777z" />
<path
id="path114"
fill="url(#a)"
d="m150.16 119.47c-0.28161 0.94013-1.2853 3.2472-2.2305 5.127l70.711 70.711c0.94515-1.8798 1.9489-4.1868 2.2305-5.127z" />
<path
id="path116"
fill="url(#a)"
d="m147.93 124.6-2 4.0059 70.711 70.711 2-4.0059z" />
<path
id="path118"
fill="url(#a)"
d="m145.93 128.6-1.1016-2.5469 70.711 70.711 1.1016 2.5469z" />
<path
id="path120"
fill="url(#a)"
d="m144.83 126.06c-0.50786-1.1277-2.6738-6.8816-5.1504-12.898l70.711 70.711c2.4766 6.0168 4.6425 11.771 5.1504 12.898z" />
<path
id="path122"
fill="url(#a)"
d="m139.68 113.16c-5.7036-13.857-11.141-23.408-17.401-29.668l70.711 70.711c6.2598 6.2598 11.697 15.811 17.401 29.668z" />
<path
id="path124"
fill="url(#a)"
d="m122.28 83.49c-6.0835-6.0835-12.944-9.058-21.58-9.8534l70.711 70.711c8.6358 0.79538 15.496 3.7699 21.58 9.8534z" />
<path
id="path126"
fill="url(#a)"
d="m100.7 73.637c-0.8718-0.08029-1.5892-0.13573-2.1641-0.16406l70.711 70.711c0.57491 0.0283 1.2923 0.0838 2.1641 0.16407z" />
</g>
<path
id="path130"
fill="url(#a)"
d="m98.535 73.473c-4.0243-0.19833-1.1175 0.96369 3.918 4.7305 6.1387 4.592 12.047 11.094 15.006 15.666 2.9912 4.6225 5.6692 8.3037 12.262 28.254 2.4794 7.5032 5.7538 14.14 6.6211 16.113l1.3476 3.0059c-0.0653 0.0814-0.11927 0.14322-0.18554 0.22656l-3.4395 3.0098c-5.5481 5.7637-16.033 11.691-24.285 13.729-6.3614 1.5706-21.301 1.1651-27.809 0.0137-17.584-3.1111-27.647-17.73-31.035-32.051-1.5987-9.5426 0.52116-16.959 1.6895-21.707 2.6637-10.825 9.7356-20.465 18.479-26.402 4.5085-3.0615 6.0316-3.0332 2.6191-3.0332-3.9668 0-10.077 2.6389-14.275 6.0215-8.0199 6.461-14.768 15.598-18.961 26.336-2.2433 6.6848-2.2747 5.5109-2.418 15.162 0.37633 6.9829 1.0389 9.3071 3.0723 15.406 3.7252 11.174 11.436 20.03 21.352 26.033 4.9985 3.0261 15.324 6.7023 22.098 7.8027 1.8904 0.30967 4.992 0.90865 7.8848 1.1875 12.195 0.94625 15.055-0.32666 24.506-1.8418 8.5906-2.4983 16.678-7.275 22.934-12.367l5.7871-4.7168c0.0891 0.11275 0.1968 0.26762 0.28321 0.375l1.9648 2.0644c6.134 6.4426 12.296 9.8178 20.275 9.2246 7.5551-0.56163 13.041-2.6761 17.49-6.7422 4.5132-4.1248 5.5354-6.236 7.6699-12.512 2.9552-8.6889 4.3184-16.193 3.9922-29.721-0.33294-13.818-1.6567-19.14-5.4434-23.453-3.1472-3.3774-6.7785-3.4556-8.8613 1.5293-0.79737 1.9089-0.78178 2.91 0.11132 6.918 1.6289 7.3102 1.7837 25.828 0.45313 32.072-2.7904 13.095-11.463 17.859-22.207 12.49-2.4274-1.213-3.8861-1.9048-5.748-3.5762-0.18578-0.19582-0.32768-0.35348-0.50391-0.54101 1.047-1.7899 1.5781-3.0116 2.7754-4.9883 4.9715-8.2077 9.7196-21.436 11.604-32.795 1.3511-8.1466 0.63728-9.9421-1.9707-11.746-2.0094-0.98322-2.8331-1.0491-4.9531-0.39844-1.3988 0.42933-3.0221 1.3082-3.6055 1.9531-0.58363 0.64492-1.9734 4.6467-3.0879 8.8945-1.1144 4.2479-2.7555 9.2858-3.6465 11.193-0.89099 1.9076-1.8493 4.2376-2.1309 5.1777-0.28161 0.94013-1.2853 3.2472-2.2305 5.127l-2 4.0059-1.1016-2.5469c-0.50786-1.1277-2.6738-6.8816-5.1504-12.898-11.247-27.323-21.459-37.908-38.98-39.522-0.8718-0.080295-1.5892-0.13573-2.1641-0.16406z" />
</g>
</clipPath>
<linearGradient
xlink:href="#c"
gradientUnits="userSpaceOnUse"
y2="210.2"
y1="153.65"
x2="201.21"
x1="146.2"
id="a" />
<clipPath
id="f">
<circle
id="circle136"
opacity=".1"
clip-path="url(#e)"
r="29.772"
cy="2.6396"
cx="40.292"
transform="matrix(1.0037 0 0 1.0037 1.3836 -1.2671)" />
</clipPath>
<linearGradient
gradientUnits="userSpaceOnUse"
y2="22.179"
y1="6.5707"
x2="61.309"
x1="45.701"
id="b">
<stop
id="stop139"
offset="0" />
<stop
id="stop141"
offset="1"
stop-opacity="0" />
</linearGradient>
<filter
color-interpolation-filters="sRGB"
height="1.048"
width="1.048"
y="-.024"
x="-.024"
id="d">
<feGaussianBlur
id="feGaussianBlur144"
stdDeviation="13.532652" />
</filter>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath4599">
<circle
cx="725.02002"
cy="-684.40002"
id="circle4601"
style="display:inline;fill:#2588d0;stroke-width:1"
r="666.01202" />
</clipPath>
<linearGradient
inkscape:collect="always"
xlink:href="#b"
id="linearGradient4609"
x1="932.39093"
y1="-448.59128"
x2="1103.4291"
y2="-292.29779"
gradientUnits="userSpaceOnUse" />
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient4780"
id="radialGradient4782"
cx="725.02002"
cy="-684.40002"
fx="725.02002"
fy="-684.40002"
r="666.01202"
gradientUnits="userSpaceOnUse" />
</defs>
<g
style="display:inline"
id="g177"
transform="translate(-8.0628,99.804)">
<g
style="display:inline"
id="g175"
transform="matrix(2,0,0,2,-5.3112,-36.986)">
<g
style="display:inline"
id="g173"
transform="matrix(0.046875,0,0,0.046875,6.3858,34.613)">
<g
style="display:inline"
id="g157">
<circle
style="display:inline;opacity:0.36000001;filter:url(#d)"
id="circle149"
r="676.63"
cy="-672.09003"
cx="710.14001"
transform="matrix(0.98504,0,0,0.98504,29.403,-5.6005)" />
<circle
r="666.01202"
style="display:inline;fill:url(#radialGradient4782);fill-opacity:1"
id="ellipse153"
cy="-684.40002"
cx="725.02002" />
<path
style="opacity:0.1;fill:url(#linearGradient4609);fill-opacity:1.0;stroke:#000000;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 502.01302,-949.02227 384.24915,383.32798 -3.43645,-89.82106 -77.68618,-110.93471 -187.18224,-187.80303 137.91285,50.53567 165.36793,165.01042 87.04612,-110.32758 124.5448,123.76483 1.5739,-125.34778 679.4554,673.25412 -544.5472,559.35788 -866.24945,-865.97293 -82.57017,-258.03173 64.87656,-135.65098 z"
id="path4595"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccccccccc"
clip-path="url(#clipPath4599)" />
</g>
<g
style="display:inline;stroke-width:0.052917"
id="g171"
transform="matrix(21.768,0,0,21.716,-148.18,-741.68)">
<g
style="display:inline;stroke:#efefef"
id="g167">
<g
style="display:inline;stroke:#efefef;stroke-width:0.052917"
id="g165">
<g
style="display:inline;stroke-width:0.052853"
id="g163"
transform="matrix(1.0012,0,0,1.0012,-0.2806,-0.13189)">
<path
style="display:inline;fill:#fefefe;stroke:#e9e9eb"
id="path159"
d="M 33.997,14.956 C 27.2465,14.30008 22.721,10.953 21.162,5.4628 c -0.97232,-3.4236 -0.40007,-6.6554 1.7689,-9.9897 1.963,-3.0177 4.5842,-4.9793 6.6538,-4.9793 h 0.55721 l -0.68124,0.45556 c -2.3327,1.5599 -4.1732,3.9523 -5.0456,6.5585 -0.51198,1.5295 -0.64528,4.5724 -0.27628,6.3067 0.63604,2.9894 2.4635,5.3876 5.0854,6.6734 1.7788,0.87238 3.409,1.2191 5.7324,1.2191 3.8422,0 6.9587,-1.203 9.6137,-3.711 l 0.90726,-0.85704 -0.45582,-1.0092 c -0.40414,-0.89475 -1.3238,-3.3221 -3.0074,-7.9377 -0.66266,-1.8167 -1.3902,-3.0367 -2.6217,-4.3966 -0.83567,-0.92274 -2.4857,-2.3558 -3.8598,-3.3523 -0.41933,-0.3041 -0.41152,-0.30624 0.78481,-0.21562 4.547,0.34444 6.8542,2.8969 10.192,11.276 0.58286,1.463 0.84878,2.0983 0.96426,2.3599 0.02953,0.0669 0.07669,0.21387 0.13247,0.16887 0.05579,-0.044998 0.2425,-0.39246 0.30776,-0.50578 0.06527,-0.11332 0.13756,-0.25964 0.22146,-0.43667 0.0839,-0.17703 0.1794,-0.38476 0.29108,-0.62088 0.81832,-1.7301 1.8142,-4.5739 2.1755,-6.2124 0.17685,-0.80191 0.31139,-1.0422 0.6944,-1.2403 1.2546,-0.64879 2.3739,-0.046158 2.3739,1.2782 0,2.3978 -1.562,7.2043 -3.3553,10.325 -0.11458,0.1994 -0.2299,0.32992 -0.28958,0.44825 -0.05969,0.11833 -0.22117,0.32942 -0.18689,0.38982 0.0262,0.046164 0.03631,0.058028 0.07857,0.1003 0.04494,0.044952 0.10546,0.1025 0.18606,0.16729 0.59444,0.47791 2.1261,1.0701 2.9132,1.178 1.0012,0.13722 2.1429,-0.34975 2.7808,-1.186 1.4436,-1.8927 1.7772,-5.2915 1.0067,-10.256 -0.28139,-1.8131 -0.2804,-1.8507 0.0612,-2.3095 0.8275,-1.1115 1.8285,-0.9019 2.5288,0.52951 1.4339,2.9307 1.2081,10.094 -0.43371,13.76 -0.9119,2.0362 -2.4949,3.1576 -5.135,3.6376 -2.1119,0.38401 -3.6271,-0.10683 -5.3738,-1.7407 l -1.0573,-0.98905 -0.49724,0.52519 c -1.2916,1.3642 -4.2775,3.0339 -6.4849,3.6264 -1.0871,0.29178 -3.7581,0.64007 -4.619,0.60228 -0.28227,-0.01239 -1.0906,-0.07862 -1.7963,-0.14719 z"
inkscape:connector-curvature="0" />
<path
style="display:inline;fill:#e9e9eb;stroke:#efefef"
id="path161"
d="M 49.244,6.7482 C 48.93619,6.36517 48.43741,5.6114 48.1356,5.0733 L 47.58686,4.09477 48.05882,3.2394 C 48.3184,2.76894 48.70519,1.8739 48.94989,1.2573 l 0.39948,-0.98883 1.3378,1.3563 c 0.8003,0.81132 1.266,1.4388 1.2301,1.6104 -0.08034,0.3845 -1.217,2.7582 -1.713,3.561 l -0.40066,0.6485 z"
inkscape:connector-curvature="0" />
</g>
</g>
</g>
<path
style="display:inline;fill:none;stroke:#e9e9eb"
id="path169"
d="m 45.251,7.0162 c 0,0 0.6208,1.4785 1.9195,3.2142"
inkscape:connector-curvature="0" />
</g>
</g>
</g>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.2" width="180mm" height="180mm" viewBox="0 0 18000 18000" preserveAspectRatio="xMidYMid" fill-rule="evenodd" stroke-width="28.222" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" xmlns:ooo="http://xml.openoffice.org/svg/export" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:presentation="http://sun.com/xmlns/staroffice/presentation" xmlns:smil="http://www.w3.org/2001/SMIL20/" xmlns:anim="urn:oasis:names:tc:opendocument:xmlns:animation:1.0" xml:space="preserve">
<defs class="ClipPathGroup">
<clipPath id="presentation_clip_path" clipPathUnits="userSpaceOnUse">
<rect x="0" y="0" width="18000" height="18000"/>
</clipPath>
<clipPath id="presentation_clip_path_shrink" clipPathUnits="userSpaceOnUse">
<rect x="18" y="18" width="17964" height="17964"/>
</clipPath>
</defs>
<defs class="TextShapeIndex">
<g ooo:slide="id1" ooo:id-list="id3 id4 id5 id6 id7"/>
</defs>
<defs class="EmbeddedBulletChars">
<g id="bullet-char-template-57356" transform="scale(0.00048828125,-0.00048828125)">
<path d="M 580,1141 L 1163,571 580,0 -4,571 580,1141 Z"/>
</g>
<style
id="style179">.st0{fill:#e0e0e0}.st1{fill:#fff}.st2{clip-path:url(#SVGID_2_);fill:#fbbc05}.st3{clip-path:url(#SVGID_4_);fill:#ea4335}.st4{clip-path:url(#SVGID_6_);fill:#34a853}.st5{clip-path:url(#SVGID_8_);fill:#4285f4}</style>
</svg>
<g id="bullet-char-template-57354" transform="scale(0.00048828125,-0.00048828125)">
<path d="M 8,1128 L 1137,1128 1137,0 8,0 8,1128 Z"/>
</g>
<g id="bullet-char-template-10146" transform="scale(0.00048828125,-0.00048828125)">
<path d="M 174,0 L 602,739 174,1481 1456,739 174,0 Z M 1358,739 L 309,1346 659,739 1358,739 Z"/>
</g>
<g id="bullet-char-template-10132" transform="scale(0.00048828125,-0.00048828125)">
<path d="M 2015,739 L 1276,0 717,0 1260,543 174,543 174,936 1260,936 717,1481 1274,1481 2015,739 Z"/>
</g>
<g id="bullet-char-template-10007" transform="scale(0.00048828125,-0.00048828125)">
<path d="M 0,-2 C -7,14 -16,27 -25,37 L 356,567 C 262,823 215,952 215,954 215,979 228,992 255,992 264,992 276,990 289,987 310,991 331,999 354,1012 L 381,999 492,748 772,1049 836,1024 860,1049 C 881,1039 901,1025 922,1006 886,937 835,863 770,784 769,783 710,716 594,584 L 774,223 C 774,196 753,168 711,139 L 727,119 C 717,90 699,76 672,76 641,76 570,178 457,381 L 164,-76 C 142,-110 111,-127 72,-127 30,-127 9,-110 8,-76 1,-67 -2,-52 -2,-32 -2,-23 -1,-13 0,-2 Z"/>
</g>
<g id="bullet-char-template-10004" transform="scale(0.00048828125,-0.00048828125)">
<path d="M 285,-33 C 182,-33 111,30 74,156 52,228 41,333 41,471 41,549 55,616 82,672 116,743 169,778 240,778 293,778 328,747 346,684 L 369,508 C 377,444 397,411 428,410 L 1163,1116 C 1174,1127 1196,1133 1229,1133 1271,1133 1292,1118 1292,1087 L 1292,965 C 1292,929 1282,901 1262,881 L 442,47 C 390,-6 338,-33 285,-33 Z"/>
</g>
<g id="bullet-char-template-9679" transform="scale(0.00048828125,-0.00048828125)">
<path d="M 813,0 C 632,0 489,54 383,161 276,268 223,411 223,592 223,773 276,916 383,1023 489,1130 632,1184 813,1184 992,1184 1136,1130 1245,1023 1353,916 1407,772 1407,592 1407,412 1353,268 1245,161 1136,54 992,0 813,0 Z"/>
</g>
<g id="bullet-char-template-8226" transform="scale(0.00048828125,-0.00048828125)">
<path d="M 346,457 C 273,457 209,483 155,535 101,586 74,649 74,723 74,796 101,859 155,911 209,963 273,989 346,989 419,989 480,963 531,910 582,859 608,796 608,723 608,648 583,586 532,535 482,483 420,457 346,457 Z"/>
</g>
<g id="bullet-char-template-8211" transform="scale(0.00048828125,-0.00048828125)">
<path d="M -4,459 L 1135,459 1135,606 -4,606 -4,459 Z"/>
</g>
<g id="bullet-char-template-61548" transform="scale(0.00048828125,-0.00048828125)">
<path d="M 173,740 C 173,903 231,1043 346,1159 462,1274 601,1332 765,1332 928,1332 1067,1274 1183,1159 1299,1043 1357,903 1357,740 1357,577 1299,437 1183,322 1067,206 928,148 765,148 601,148 462,206 346,322 231,437 173,577 173,740 Z"/>
</g>
</defs>
<defs class="TextEmbeddedBitmaps"/>
<g>
<g id="id2" class="Master_Slide">
<g id="bg-id2" class="Background"/>
<g id="bo-id2" class="BackgroundObjects"/>
</g>
</g>
<g class="SlideGroup">
<g>
<g id="container-id1">
<g id="id1" class="Slide" clip-path="url(#presentation_clip_path)">
<g class="Page">
<g class="com.sun.star.drawing.CustomShape">
<g id="id3">
<rect class="BoundingBox" stroke="none" fill="none" x="-6" y="-6" width="18014" height="18014"/>
<path fill="rgb(56,142,60)" stroke="none" d="M 9000,100 C 14046,100 17900,3954 17900,9000 17900,14046 14046,17900 9000,17900 3954,17900 100,14046 100,9000 100,3954 3954,100 9000,100 Z"/>
<path fill="none" stroke="rgb(0,0,0)" stroke-width="212" stroke-linejoin="round" d="M 9000,100 C 14046,100 17900,3954 17900,9000 17900,14046 14046,17900 9000,17900 3954,17900 100,14046 100,9000 100,3954 3954,100 9000,100 Z"/>
</g>
</g>
<g class="com.sun.star.drawing.CustomShape">
<g id="id4">
<rect class="BoundingBox" stroke="none" fill="none" x="1094" y="994" width="15813" height="15813"/>
<path fill="rgb(231,231,231)" stroke="none" d="M 9000,1100 C 13422,1100 16800,4478 16800,8900 16800,13322 13422,16700 9000,16700 4578,16700 1200,13322 1200,8900 1200,4478 4578,1100 9000,1100 Z"/>
<path fill="none" stroke="rgb(0,0,0)" stroke-width="212" stroke-linejoin="round" d="M 9000,1100 C 13422,1100 16800,4478 16800,8900 16800,13322 13422,16700 9000,16700 4578,16700 1200,13322 1200,8900 1200,4478 4578,1100 9000,1100 Z"/>
</g>
</g>
<g class="Group">
<g class="com.sun.star.drawing.ClosedBezierShape">
<g id="id5">
<rect class="BoundingBox" stroke="none" fill="none" x="1265" y="1958" width="7446" height="12870"/>
<path fill="rgb(0,0,0)" stroke="none" d="M 5401,2000 C 5190,2137 4655,2216 4001,2800 3160,3551 2170,4881 1901,5500 625,8597 1313,13283 3601,14600 3643,14621 3768,14533 3801,14500 3834,14467 3801,14433 3801,14400 4501,14700 5001,14900 5801,14800 6316,14701 6847,14367 7101,14000 7601,13200 7254,13438 7401,13300 6701,13000 5501,12900 5201,12200 5027,11781 4897,11621 4801,11300 4727,11051 4683,10737 4601,10500 5268,10733 5990,11177 6601,11200 7212,11223 7949,11138 8101,10800 8253,10462 8032,10520 7801,10200 7570,9880 7068,9467 6701,9100 7168,9067 7744,9129 8101,9000 8458,8871 8614,8942 8701,8500 8788,8058 8197,7274 7801,6800 7405,6326 6868,6067 6401,5700 6468,5533 6567,5400 6601,5200 6635,5000 6424,4712 6601,4500 6778,4288 7300,4417 7501,4200 7702,3983 7736,3632 7701,3400 7666,3168 7647,3107 7301,2800 6955,2493 5776,1783 5401,2000 Z"/>
<path fill="none" stroke="rgb(0,0,0)" d="M 5401,2000 C 5190,2137 4655,2216 4001,2800 3160,3551 2170,4881 1901,5500 625,8597 1313,13283 3601,14600 3643,14621 3768,14533 3801,14500 3834,14467 3801,14433 3801,14400 4501,14700 5001,14900 5801,14800 6316,14701 6847,14367 7101,14000 7601,13200 7254,13438 7401,13300 6701,13000 5501,12900 5201,12200 5027,11781 4897,11621 4801,11300 4727,11051 4683,10737 4601,10500 5268,10733 5990,11177 6601,11200 7212,11223 7949,11138 8101,10800 8253,10462 8032,10520 7801,10200 7570,9880 7068,9467 6701,9100 7168,9067 7744,9129 8101,9000 8458,8871 8614,8942 8701,8500 8788,8058 8197,7274 7801,6800 7405,6326 6868,6067 6401,5700 6468,5533 6567,5400 6601,5200 6635,5000 6424,4712 6601,4500 6778,4288 7300,4417 7501,4200 7702,3983 7736,3632 7701,3400 7666,3168 7647,3107 7301,2800 6955,2493 5776,1783 5401,2000 Z"/>
</g>
</g>
<g class="com.sun.star.drawing.ClosedBezierShape">
<g id="id6">
<rect class="BoundingBox" stroke="none" fill="none" x="10569" y="1898" width="5534" height="3904"/>
<path fill="rgb(0,0,0)" stroke="none" d="M 12400,1900 C 12077,1874 10510,2796 10601,3100 10469,3201 10771,3800 11301,4100 12890,4999 15894,5652 16101,5800 16090,5800 15987,5038 15100,4000 14410,3192 13119,1957 12400,1900 Z"/>
<path fill="none" stroke="rgb(0,0,0)" d="M 12400,1900 C 12077,1874 10510,2796 10601,3100 10469,3201 10771,3800 11301,4100 12890,4999 15894,5652 16101,5800 16090,5800 15987,5038 15100,4000 14410,3192 13119,1957 12400,1900 Z"/>
</g>
</g>
<g class="com.sun.star.drawing.ClosedBezierShape">
<g id="id7">
<rect class="BoundingBox" stroke="none" fill="none" x="9094" y="4800" width="7691" height="10403"/>
<path fill="rgb(0,0,0)" stroke="none" d="M 11901,4801 C 16301,6501 16401,6501 16401,6501 16401,6501 17106,9347 16600,10900 16371,11648 15622,13159 15001,14001 14621,14516 13708,15222 13701,15201 13701,15201 12457,15112 11901,14801 11424,14534 10801,13601 10801,13601 10801,13601 13076,13181 13801,12401 14297,11868 14401,10401 14401,10401 14401,10401 13234,11134 12501,11201 11768,11268 10395,11342 10001,10701 9928,10584 10189,10442 10301,10301 10731,9757 11901,9101 11901,9101 11901,9101 10203,9101 9601,8901 9259,8787 9132,8735 9101,8501 9030,7968 9628,7388 10001,7001 10573,6409 12101,5701 12101,5701 12101,5701 11918,5337 11900,5201 11881,5036 11901,4801 11901,4801 Z"/>
<path fill="none" stroke="rgb(0,0,0)" d="M 11901,4801 C 16301,6501 16401,6501 16401,6501 16401,6501 17106,9347 16600,10900 16371,11648 15622,13159 15001,14001 14621,14516 13708,15222 13701,15201 13701,15201 12457,15112 11901,14801 11424,14534 10801,13601 10801,13601 10801,13601 13076,13181 13801,12401 14297,11868 14401,10401 14401,10401 14401,10401 13234,11134 12501,11201 11768,11268 10395,11342 10001,10701 9928,10584 10189,10442 10301,10301 10731,9757 11901,9101 11901,9101 11901,9101 10203,9101 9601,8901 9259,8787 9132,8735 9101,8501 9030,7968 9628,7388 10001,7001 10573,6409 12101,5701 12101,5701 12101,5701 11918,5337 11900,5201 11881,5036 11901,4801 11901,4801 Z"/>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

View file

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

View file

@ -38,6 +38,7 @@ import androidx.preference.PreferenceManager;
import com.google.android.material.snackbar.Snackbar;
import com.keylesspalace.tusky.adapter.AccountSelectionAdapter;
import com.keylesspalace.tusky.components.login.LoginActivity;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable;
@ -197,6 +198,33 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
.show();
}
public @Nullable String getOpenAsText() {
List<AccountEntity> accounts = accountManager.getAllAccountsOrderedByActive();
switch (accounts.size()) {
case 0:
case 1:
return null;
case 2:
for (AccountEntity account : accounts) {
if (account != accountManager.getActiveAccount()) {
return String.format(getString(R.string.action_open_as), account.getFullName());
}
}
return null;
default:
return String.format(getString(R.string.action_open_as), "");
}
}
public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) {
accountManager.setActiveAccount(account);
Intent intent = new Intent(this, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
intent.putExtra(MainActivity.REDIRECT_URL, url);
startActivity(intent);
finishWithoutSlideOutAnimation();
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);

View file

@ -15,6 +15,7 @@
package com.keylesspalace.tusky
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
@ -27,7 +28,7 @@ import autodispose2.autoDispose
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.openLink
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import java.net.URI
import java.net.URISyntaxException
@ -157,9 +158,9 @@ abstract class BottomSheetActivity : BaseActivity() {
}
}
@VisibleForTesting
@VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
open fun openLink(url: String) {
LinkHelper.openLink(url, this)
(this as Context).openLink(url)
}
private fun showQuerySheet() {

View file

@ -15,28 +15,26 @@
package com.keylesspalace.tusky
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ImageView
import androidx.activity.viewModels
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.lifecycle.LiveData
import androidx.recyclerview.widget.LinearLayoutManager
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.FitCenter
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.canhub.cropper.CropImage
import com.canhub.cropper.CropImageContract
import com.canhub.cropper.options
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter
import com.keylesspalace.tusky.databinding.ActivityEditProfileBinding
@ -44,9 +42,7 @@ import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
@ -63,12 +59,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
const val HEADER_WIDTH = 1500
const val HEADER_HEIGHT = 500
private const val AVATAR_PICK_RESULT = 1
private const val HEADER_PICK_RESULT = 2
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
private const val MAX_ACCOUNT_FIELDS = 4
private const val BUNDLE_CURRENTLY_PICKING = "BUNDLE_CURRENTLY_PICKING"
}
@Inject
@ -78,23 +69,28 @@ class EditProfileActivity : BaseActivity(), Injectable {
private val binding by viewBinding(ActivityEditProfileBinding::inflate)
private var currentlyPicking: PickType = PickType.NOTHING
private val accountFieldEditAdapter = AccountFieldEditAdapter()
private enum class PickType {
NOTHING,
AVATAR,
HEADER
}
private val cropImage = registerForActivityResult(CropImageContract()) { result ->
if (result.isSuccessful) {
if (result.uriContent == viewModel.getAvatarUri()) {
viewModel.newAvatarPicked()
} else {
viewModel.newHeaderPicked()
}
} else {
onPickFailure(result.error)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
savedInstanceState?.getString(BUNDLE_CURRENTLY_PICKING)?.let {
currentlyPicking = PickType.valueOf(it)
}
setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar)
@ -104,8 +100,8 @@ class EditProfileActivity : BaseActivity(), Injectable {
setDisplayShowHomeEnabled(true)
}
binding.avatarButton.setOnClickListener { onMediaPick(PickType.AVATAR) }
binding.headerButton.setOnClickListener { onMediaPick(PickType.HEADER) }
binding.avatarButton.setOnClickListener { pickMedia(PickType.AVATAR) }
binding.headerButton.setOnClickListener { pickMedia(PickType.HEADER) }
binding.fieldList.layoutManager = LinearLayoutManager(this)
binding.fieldList.adapter = accountFieldEditAdapter
@ -159,11 +155,11 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
}
is Error -> {
val snackbar = Snackbar.make(binding.avatarButton, R.string.error_generic, Snackbar.LENGTH_LONG)
snackbar.setAction(R.string.action_retry) {
viewModel.obtainProfile()
}
snackbar.show()
Snackbar.make(binding.avatarButton, R.string.error_generic, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry) {
viewModel.obtainProfile()
}
.show()
}
is Loading -> { }
}
@ -179,30 +175,24 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
}
observeImage(viewModel.avatarData, binding.avatarPreview, binding.avatarProgressBar, true)
observeImage(viewModel.headerData, binding.headerPreview, binding.headerProgressBar, false)
observeImage(viewModel.avatarData, binding.avatarPreview, true)
observeImage(viewModel.headerData, binding.headerPreview, false)
viewModel.saveData.observe(
this,
{
when (it) {
is Success -> {
finish()
}
is Loading -> {
binding.saveProgressBar.visibility = View.VISIBLE
}
is Error -> {
onSaveFailure(it.errorMessage)
}
this
) {
when (it) {
is Success -> {
finish()
}
is Loading -> {
binding.saveProgressBar.visibility = View.VISIBLE
}
is Error -> {
onSaveFailure(it.errorMessage)
}
}
)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString(BUNDLE_CURRENTLY_PICKING, currentlyPicking.toString())
}
}
override fun onStop() {
@ -218,90 +208,60 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
private fun observeImage(
liveData: LiveData<Resource<Bitmap>>,
liveData: LiveData<Uri>,
imageView: ImageView,
progressBar: View,
roundedCorners: Boolean
) {
liveData.observe(
this,
{
this
) { imageUri ->
when (it) {
is Success -> {
val glide = Glide.with(imageView)
.load(it.data)
// skipping all caches so we can always reuse the same uri
val glide = Glide.with(imageView)
.load(imageUri)
.skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.NONE)
if (roundedCorners) {
glide.transform(
FitCenter(),
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
)
}
glide.into(imageView)
imageView.show()
progressBar.hide()
}
is Loading -> {
progressBar.show()
}
is Error -> {
progressBar.hide()
if (!it.consumed) {
onResizeFailure()
it.consumed = true
}
}
}
if (roundedCorners) {
glide.transform(
FitCenter(),
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
).into(imageView)
} else {
glide.into(imageView)
}
)
}
private fun onMediaPick(pickType: PickType) {
if (currentlyPicking != PickType.NOTHING) {
// Ignore inputs if another pick operation is still occurring.
return
}
currentlyPicking = pickType
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE)
} else {
initiateMediaPicking()
imageView.show()
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE -> {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
initiateMediaPicking()
} else {
endMediaPicking()
Snackbar.make(binding.avatarButton, R.string.error_media_upload_permission, Snackbar.LENGTH_LONG).show()
}
}
}
}
private fun initiateMediaPicking() {
private fun pickMedia(pickType: PickType) {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "image/*"
when (currentlyPicking) {
when (pickType) {
PickType.AVATAR -> {
startActivityForResult(intent, AVATAR_PICK_RESULT)
cropImage.launch(
options {
setRequestedSize(AVATAR_SIZE, AVATAR_SIZE)
setAspectRatio(AVATAR_SIZE, AVATAR_SIZE)
setImageSource(includeGallery = true, includeCamera = false)
setOutputUri(viewModel.getAvatarUri())
setOutputCompressFormat(Bitmap.CompressFormat.PNG)
}
)
}
PickType.HEADER -> {
startActivityForResult(intent, HEADER_PICK_RESULT)
cropImage.launch(
options {
setRequestedSize(HEADER_WIDTH, HEADER_HEIGHT)
setAspectRatio(HEADER_WIDTH, HEADER_HEIGHT)
setImageSource(includeGallery = true, includeCamera = false)
setOutputUri(viewModel.getHeaderUri())
setOutputCompressFormat(Bitmap.CompressFormat.PNG)
}
)
}
PickType.NOTHING -> { /* do nothing */ }
}
}
@ -321,16 +281,11 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
private fun save() {
if (currentlyPicking != PickType.NOTHING) {
return
}
viewModel.save(
binding.displayNameEditText.text.toString(),
binding.noteEditText.text.toString(),
binding.lockedCheckBox.isChecked,
accountFieldEditAdapter.getFieldData(),
this
accountFieldEditAdapter.getFieldData()
)
}
@ -340,90 +295,8 @@ class EditProfileActivity : BaseActivity(), Injectable {
binding.saveProgressBar.visibility = View.GONE
}
private fun beginMediaPicking() {
when (currentlyPicking) {
PickType.AVATAR -> {
binding.avatarProgressBar.visibility = View.VISIBLE
binding.avatarPreview.visibility = View.INVISIBLE
binding.avatarButton.setImageDrawable(null)
}
PickType.HEADER -> {
binding.headerProgressBar.visibility = View.VISIBLE
binding.headerPreview.visibility = View.INVISIBLE
binding.headerButton.setImageDrawable(null)
}
PickType.NOTHING -> { /* do nothing */ }
}
}
private fun endMediaPicking() {
binding.avatarProgressBar.visibility = View.GONE
binding.headerProgressBar.visibility = View.GONE
currentlyPicking = PickType.NOTHING
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
AVATAR_PICK_RESULT -> {
if (resultCode == Activity.RESULT_OK && data != null) {
CropImage.activity(data.data)
.setInitialCropWindowPaddingRatio(0f)
.setOutputCompressFormat(Bitmap.CompressFormat.PNG)
.setAspectRatio(AVATAR_SIZE, AVATAR_SIZE)
.start(this)
} else {
endMediaPicking()
}
}
HEADER_PICK_RESULT -> {
if (resultCode == Activity.RESULT_OK && data != null) {
CropImage.activity(data.data)
.setInitialCropWindowPaddingRatio(0f)
.setOutputCompressFormat(Bitmap.CompressFormat.PNG)
.setAspectRatio(HEADER_WIDTH, HEADER_HEIGHT)
.start(this)
} else {
endMediaPicking()
}
}
CropImage.CROP_IMAGE_ACTIVITY_REQUEST_CODE -> {
val result = CropImage.getActivityResult(data)
when (resultCode) {
Activity.RESULT_OK -> beginResize(result?.uriContent)
CropImage.CROP_IMAGE_ACTIVITY_RESULT_ERROR_CODE -> onResizeFailure()
else -> endMediaPicking()
}
}
}
}
private fun beginResize(uri: Uri?) {
if (uri == null) {
currentlyPicking = PickType.NOTHING
return
}
beginMediaPicking()
when (currentlyPicking) {
PickType.AVATAR -> {
viewModel.newAvatar(uri, this)
}
PickType.HEADER -> {
viewModel.newHeader(uri, this)
}
else -> {
throw AssertionError("PickType not set.")
}
}
currentlyPicking = PickType.NOTHING
}
private fun onResizeFailure() {
private fun onPickFailure(throwable: Throwable?) {
Log.w("EditProfileActivity", "failed to pick media", throwable)
Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show()
endMediaPicking()
}
}

View file

@ -40,7 +40,6 @@ import at.connyduck.sparkbutton.helpers.Utils
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.databinding.ActivityListsBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
@ -201,7 +200,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
private fun onListSelected(listId: String) {
startActivityWithSlideInAnimation(
ModalTimelineActivity.newIntent(this, TimelineViewModel.Kind.LIST, listId)
StatusListActivity.newListIntent(this, listId)
)
}

View file

@ -1,381 +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.rickRoll
import com.keylesspalace.tusky.util.shouldRickRoll
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.registerButton.setOnClickListener { onRegisterClick() }
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)
}
}
private fun onRegisterClick() {
binding.registerButton.isEnabled = false
val uri = Uri.parse(BuildConfig.REGISTER_ACCOUNT_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)
}
}
}
/**
* 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
}
if (shouldRickRoll(this, domain)) {
rickRoll(this)
return
}
val callback = object : Callback<AppCredentials> {
override fun onResponse(
call: Call<AppCredentials>,
response: Response<AppCredentials>
) {
if (!response.isSuccessful) {
binding.loginButton.isEnabled = true
binding.domainTextInputLayout.error = getString(R.string.error_failed_app_registration)
setLoading(false)
Log.e(TAG, "App authentication failed. " + response.message())
return
}
val credentials = response.body()
val clientId = credentials!!.clientId
val clientSecret = credentials.clientSecret
preferences.edit()
.putString("domain", domain)
.putString("clientId", clientId)
.putString("clientSecret", clientSecret)
.apply()
redirectUserToAuthorizeAndLogin(domain, clientId)
}
override fun onFailure(call: Call<AppCredentials>, t: Throwable) {
binding.loginButton.isEnabled = true
binding.domainTextInputLayout.error = getString(R.string.error_failed_app_registration)
setLoading(false)
Log.e(TAG, Log.getStackTraceString(t))
}
}
mastodonApi
.authenticateApp(
domain, getString(R.string.app_name), oauthRedirectUri,
OAUTH_SCOPES, getString(R.string.tusky_website)
)
.enqueue(callback)
setLoading(true)
}
private fun redirectUserToAuthorizeAndLogin(domain: String, clientId: String) {
/* To authorize this app and log in it's necessary to redirect to the domain given,
* login there, and the server will redirect back to the app with its response. */
val endpoint = MastodonApi.ENDPOINT_AUTHORIZE
val parameters = mapOf(
"client_id" to clientId,
"redirect_uri" to oauthRedirectUri,
"response_type" to "code",
"scope" to OAUTH_SCOPES
)
val url = "https://" + domain + endpoint + "?" + toQueryString(parameters)
val uri = Uri.parse(url)
if (!openInCustomTab(uri, this)) {
val viewIntent = Intent(Intent.ACTION_VIEW, uri)
if (viewIntent.resolveActivity(packageManager) != null) {
startActivity(viewIntent)
} else {
binding.domainEditText.error = getString(R.string.error_no_web_browser_found)
setLoading(false)
}
}
}
override fun onStart() {
super.onStart()
/* Check if we are resuming during authorization by seeing if the intent contains the
* redirect that was given to the server. If so, its response is here! */
val uri = intent.data
val redirectUri = oauthRedirectUri
if (uri != null && uri.toString().startsWith(redirectUri)) {
// This should either have returned an authorization code or an error.
val code = uri.getQueryParameter("code")
val error = uri.getQueryParameter("error")
/* restore variables from SharedPreferences */
val domain = preferences.getNonNullString(DOMAIN, "")
val clientId = preferences.getNonNullString(CLIENT_ID, "")
val clientSecret = preferences.getNonNullString(CLIENT_SECRET, "")
if (code != null && domain.isNotEmpty() && clientId.isNotEmpty() && clientSecret.isNotEmpty()) {
setLoading(true)
/* Since authorization has succeeded, the final step to log in is to exchange
* the authorization code for an access token. */
val callback = object : Callback<AccessToken> {
override fun onResponse(call: Call<AccessToken>, response: Response<AccessToken>) {
if (response.isSuccessful) {
onLoginSuccess(response.body()!!.accessToken, domain)
} else {
setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token)
Log.e(TAG, "%s %s".format(getString(R.string.error_retrieving_oauth_token), response.message()))
}
}
override fun onFailure(call: Call<AccessToken>, t: Throwable) {
setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token)
Log.e(TAG, "%s %s".format(getString(R.string.error_retrieving_oauth_token), t.message))
}
}
mastodonApi.fetchOAuthToken(
domain, clientId, clientSecret, redirectUri, code,
"authorization_code"
).enqueue(callback)
} else if (error != null) {
/* Authorization failed. Put the error response where the user can read it and they
* can try again. */
setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_authorization_denied)
Log.e(TAG, "%s %s".format(getString(R.string.error_authorization_denied), error))
} else {
// This case means a junk response was received somehow.
setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_authorization_unknown)
}
} else {
// first show or user cancelled login
setLoading(false)
}
}
private fun setLoading(loadingState: Boolean) {
if (loadingState) {
binding.loginLoadingLayout.visibility = View.VISIBLE
binding.loginInputLayout.visibility = View.GONE
} else {
binding.loginLoadingLayout.visibility = View.GONE
binding.loginInputLayout.visibility = View.VISIBLE
binding.loginButton.isEnabled = true
}
}
private fun isAdditionalLogin(): Boolean {
return intent.getBooleanExtra(LOGIN_MODE, false)
}
private fun onLoginSuccess(accessToken: String, domain: String) {
setLoading(true)
accountManager.addAccount(accessToken, domain)
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
finish()
overridePendingTransition(R.anim.explode, R.anim.explode)
}
companion object {
private const val TAG = "LoginActivity" // logging tag
private const val OAUTH_SCOPES = "read write follow"
private const val LOGIN_MODE = "LOGIN_MODE"
private const val DOMAIN = "domain"
private const val CLIENT_ID = "clientId"
private const val CLIENT_SECRET = "clientSecret"
@JvmStatic
fun getIntent(context: Context, mode: Boolean): Intent {
val loginIntent = Intent(context, LoginActivity::class.java)
loginIntent.putExtra(LOGIN_MODE, mode)
return loginIntent
}
/** Make sure the user-entered text is just a fully-qualified domain name. */
private fun canonicalizeDomain(domain: String): String {
// Strip any schemes out.
var s = domain.replaceFirst("http://", "")
s = s.replaceFirst("https://", "")
// If a username was included (e.g. username@example.com), just take what's after the '@'.
val at = s.lastIndexOf('@')
if (at != -1) {
s = s.substring(at + 1)
}
return s.trim { it <= ' ' }
}
/**
* Chain together the key-value pairs into a query string, for either appending to a URL or
* as the content of an HTTP request.
*/
private fun toQueryString(parameters: Map<String, String>): String {
val s = StringBuilder()
var between = ""
for ((key, value) in parameters) {
s.append(between)
s.append(Uri.encode(key))
s.append("=")
s.append(Uri.encode(value))
between = "&"
}
return s.toString()
}
private fun openInCustomTab(uri: Uri, context: Context): Boolean {
val toolbarColor = ThemeUtils.getColor(context, R.attr.colorSurface)
val navigationbarColor = ThemeUtils.getColor(context, android.R.attr.navigationBarColor)
val navigationbarDividerColor = ThemeUtils.getColor(context, R.attr.dividerColor)
val colorSchemeParams = CustomTabColorSchemeParams.Builder()
.setToolbarColor(toolbarColor)
.setNavigationBarColor(navigationbarColor)
.setNavigationBarDividerColor(navigationbarDividerColor)
.build()
val customTabsIntent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(colorSchemeParams)
.build()
try {
customTabsIntent.launchUrl(context, uri)
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "Activity was not found for intent $customTabsIntent")
return false
}
return true
}
}
}

View file

@ -64,9 +64,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.databinding.ActivityMainBinding
import com.keylesspalace.tusky.db.AccountEntity
@ -325,9 +326,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
super.onPostCreate(savedInstanceState)
if (intent != null) {
val statusUrl = intent.getStringExtra(STATUS_URL)
if (statusUrl != null) {
viewUrl(statusUrl, PostLookupFallbackBehavior.DISPLAY_ERROR)
val redirectUrl = intent.getStringExtra(REDIRECT_URL)
if (redirectUrl != null) {
viewUrl(redirectUrl, PostLookupFallbackBehavior.DISPLAY_ERROR)
}
}
}
@ -454,10 +455,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 {
@ -834,7 +835,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private const val TAG = "MainActivity" // logging tag
private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13
private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14
const val STATUS_URL = "statusUrl"
const val REDIRECT_URL = "redirectUrl"
}
}

View file

@ -1,62 +0,0 @@
package com.keylesspalace.tusky
import android.content.Context
import android.content.Intent
import android.os.Bundle
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.databinding.ActivityModalTimelineBinding
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
class ModalTimelineActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityModalTimelineBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.apply {
title = getString(R.string.title_list_timeline)
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
if (supportFragmentManager.findFragmentById(R.id.contentFrame) == null) {
val kind = intent?.getSerializableExtra(ARG_KIND) as? TimelineViewModel.Kind
?: TimelineViewModel.Kind.HOME
val argument = intent?.getStringExtra(ARG_ARG)
supportFragmentManager.beginTransaction()
.replace(R.id.contentFrame, TimelineFragment.newInstance(kind, argument))
.commit()
}
}
override fun getActionButton(): FloatingActionButton? = null
override fun androidInjector() = dispatchingAndroidInjector
companion object {
private const val ARG_KIND = "kind"
private const val ARG_ARG = "arg"
@JvmStatic
fun newIntent(
context: Context,
kind: TimelineViewModel.Kind,
argument: String?
): Intent {
val intent = Intent(context, ModalTimelineActivity::class.java)
intent.putExtra(ARG_KIND, kind)
intent.putExtra(ARG_ARG, argument)
return intent
}
}
}

View file

@ -15,14 +15,17 @@
package com.keylesspalace.tusky
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import javax.inject.Inject
@SuppressLint("CustomSplashScreen")
class SplashActivity : AppCompatActivity(), Injectable {
@Inject

View file

@ -31,9 +31,6 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
private val kind: Kind
get() = Kind.valueOf(intent.getStringExtra(EXTRA_KIND)!!)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityStatuslistBinding.inflate(layoutInflater)
@ -41,10 +38,15 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
setSupportActionBar(binding.includedToolbar.toolbar)
val title = if (kind == Kind.FAVOURITES) {
R.string.title_favourites
} else {
R.string.title_bookmarks
val kind = Kind.valueOf(intent.getStringExtra(EXTRA_KIND)!!)
val listId = intent.getStringExtra(EXTRA_LIST_ID)
val hashtag = intent.getStringExtra(EXTRA_HASHTAG)
val title = when (kind) {
Kind.FAVOURITES -> getString(R.string.title_favourites)
Kind.BOOKMARKS -> getString(R.string.title_bookmarks)
Kind.TAG -> getString(R.string.title_tag).format(hashtag)
else -> getString(R.string.title_list_timeline)
}
supportActionBar?.run {
@ -53,9 +55,15 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
setDisplayShowHomeEnabled(true)
}
supportFragmentManager.commit {
val fragment = TimelineFragment.newInstance(kind)
replace(R.id.fragment_container, fragment)
if (supportFragmentManager.findFragmentById(R.id.fragmentContainer) == null) {
supportFragmentManager.commit {
val fragment = if (kind == Kind.TAG) {
TimelineFragment.newHashtagInstance(listOf(hashtag!!))
} else {
TimelineFragment.newInstance(kind, listId)
}
replace(R.id.fragmentContainer, fragment)
}
}
}
@ -64,17 +72,30 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
companion object {
private const val EXTRA_KIND = "kind"
private const val EXTRA_LIST_ID = "id"
private const val EXTRA_HASHTAG = "tag"
@JvmStatic
fun newFavouritesIntent(context: Context) =
Intent(context, StatusListActivity::class.java).apply {
putExtra(EXTRA_KIND, Kind.FAVOURITES.name)
}
@JvmStatic
fun newBookmarksIntent(context: Context) =
Intent(context, StatusListActivity::class.java).apply {
putExtra(EXTRA_KIND, Kind.BOOKMARKS.name)
}
fun newListIntent(context: Context, listId: String) =
Intent(context, StatusListActivity::class.java).apply {
putExtra(EXTRA_KIND, Kind.LIST.name)
putExtra(EXTRA_LIST_ID, listId)
}
@JvmStatic
fun newHashtagIntent(context: Context, hashtag: String) =
Intent(context, StatusListActivity::class.java).apply {
putExtra(EXTRA_KIND, Kind.TAG.name)
putExtra(EXTRA_HASHTAG, hashtag)
}
}
}

View file

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

View file

@ -1,79 +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.Context;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentTransaction;
import com.keylesspalace.tusky.components.timeline.TimelineFragment;
import java.util.Collections;
import javax.inject.Inject;
import dagger.android.AndroidInjector;
import dagger.android.DispatchingAndroidInjector;
import dagger.android.HasAndroidInjector;
public class ViewTagActivity extends BottomSheetActivity implements HasAndroidInjector {
private static final String HASHTAG = "hashtag";
@Inject
public DispatchingAndroidInjector<Object> dispatchingAndroidInjector;
public static Intent getIntent(Context context, String tag){
Intent intent = new Intent(context,ViewTagActivity.class);
intent.putExtra(HASHTAG,tag);
return intent;
}
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_view_tag);
String hashtag = getIntent().getStringExtra(HASHTAG);
Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
ActionBar bar = getSupportActionBar();
if (bar != null) {
bar.setTitle(String.format(getString(R.string.title_tag), hashtag));
bar.setDisplayHomeAsUpEnabled(true);
bar.setDisplayShowHomeEnabled(true);
}
FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction();
Fragment fragment = TimelineFragment.newHashtagInstance(Collections.singletonList(hashtag));
fragmentTransaction.replace(R.id.fragment_container, fragment);
fragmentTransaction.commit();
}
@Override
public AndroidInjector<Object> androidInjector() {
return dispatchingAndroidInjector;
}
}

View file

@ -111,7 +111,7 @@ public class ViewThreadActivity extends BottomSheetActivity implements HasAndroi
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.action_open_in_web: {
LinkHelper.openLink(getIntent().getStringExtra(URL_EXTRA), this);
openLink(getIntent().getStringExtra(URL_EXTRA));
return true;
}
case R.id.action_reveal: {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -40,10 +40,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;
@ -335,7 +335,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);
@ -346,7 +346,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(
@ -440,7 +440,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);
}
@ -538,9 +538,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 -> {
@ -630,10 +630,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 {
@ -644,7 +644,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
CharSequence emojifiedText = CustomEmojiHelper.emojify(
content, emojis, statusContent, statusDisplayOptions.animateEmojis()
);
LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), listener);
LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), statusViewData.getActionable().getTags(), listener);
CharSequence emojifiedContentWarning;
if (statusViewData.getSpoilerText() != null) {

View file

@ -37,6 +37,7 @@ import com.keylesspalace.tusky.entity.Attachment.Focus;
import com.keylesspalace.tusky.entity.Attachment.MetaData;
import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.HashTag;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CardViewMode;
@ -190,7 +191,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);
}
@ -202,6 +203,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
@NonNull Spanned content,
@Nullable String spoilerText,
@Nullable List<Status.Mention> mentions,
@Nullable List<HashTag> tags,
@NonNull List<Emoji> emojis,
@Nullable PollViewData poll,
@NonNull StatusDisplayOptions statusDisplayOptions,
@ -222,21 +224,21 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
setContentWarningButtonText(!expanded);
this.setTextVisible(sensitive, !expanded, content, mentions, emojis, poll, statusDisplayOptions, listener);
this.setTextVisible(sensitive, !expanded, content, mentions, tags, emojis, poll, statusDisplayOptions, listener);
});
this.setTextVisible(sensitive, expanded, content, mentions, emojis, poll, statusDisplayOptions, listener);
this.setTextVisible(sensitive, expanded, content, mentions, tags, emojis, poll, statusDisplayOptions, listener);
} else {
contentWarningDescription.setVisibility(View.GONE);
contentWarningButton.setVisibility(View.GONE);
this.setTextVisible(sensitive, true, content, mentions, emojis, poll, statusDisplayOptions, listener);
this.setTextVisible(sensitive, true, content, mentions, tags, emojis, poll, statusDisplayOptions, listener);
}
}
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);
}
}
@ -244,13 +246,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
boolean expanded,
Spanned content,
List<Status.Mention> mentions,
List<HashTag> tags,
List<Emoji> emojis,
@Nullable PollViewData poll,
StatusDisplayOptions statusDisplayOptions,
final StatusActionListener listener) {
if (expanded) {
CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis());
LinkHelper.setClickableText(this.content, emojifiedText, mentions, listener);
LinkHelper.setClickableText(this.content, emojifiedText, mentions, tags, listener);
for (int i = 0; i < mediaLabels.length; ++i) {
updateMediaLabel(i, sensitive, expanded);
}
@ -505,9 +508,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);
@ -552,7 +555,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);
}
@ -604,7 +607,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();
}
@ -739,7 +742,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
@Nullable Object payloads) {
if (payloads == null) {
Status actionable = status.getActionable();
setDisplayName(actionable.getAccount().getDisplayName(), actionable.getAccount().getEmojis(), statusDisplayOptions);
setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions);
setUsername(status.getUsername());
setCreatedAt(actionable.getCreatedAt(), statusDisplayOptions);
setIsReply(actionable.getInReplyToId() != null);
@ -779,7 +782,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility());
setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(),
actionable.getMentions(), actionable.getEmojis(),
actionable.getMentions(), actionable.getTags(), actionable.getEmojis(),
PollViewDataKt.toViewData(actionable.getPoll()), statusDisplayOptions,
listener);
@ -823,9 +826,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()),
@ -840,7 +843,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 "";
}
@ -857,20 +860,20 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
(builder, a) -> {
if (a.getDescription() == null) {
String placeholder =
context.getString(R.string.description_status_media_no_description_placeholder);
context.getString(R.string.description_post_media_no_description_placeholder);
return builder.append(placeholder);
} else {
builder.append("; ");
return builder.append(a.getDescription());
}
});
return context.getString(R.string.description_status_media, mediaDescriptions);
return context.getString(R.string.description_post_media, mediaDescriptions);
}
private static CharSequence getContentWarningDescription(Context context,
@NonNull StatusViewData.Concrete status) {
if (!TextUtils.isEmpty(status.getSpoilerText())) {
return context.getString(R.string.description_status_cw, status.getSpoilerText());
return context.getString(R.string.description_post_cw, status.getSpoilerText());
} else {
return "";
}

View file

@ -70,7 +70,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
if (reblogging == null) {
hideStatusInfo();
} else {
String rebloggedByDisplayName = reblogging.getAccount().getDisplayName();
String rebloggedByDisplayName = reblogging.getAccount().getName();
setRebloggedByDisplayName(rebloggedByDisplayName,
reblogging.getAccount().getEmojis(), statusDisplayOptions);
statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition()));
@ -86,7 +86,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
final StatusDisplayOptions statusDisplayOptions) {
Context context = statusInfo.getContext();
CharSequence wrappedName = StringUtils.unicodeWrap(name);
CharSequence boostedText = context.getString(R.string.status_boosted_format, wrappedName);
CharSequence boostedText = context.getString(R.string.post_boosted_format, wrappedName);
CharSequence emojifiedText = CustomEmojiHelper.emojify(
boostedText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis()
);
@ -118,10 +118,10 @@ public class StatusViewHolder extends StatusBaseViewHolder {
contentCollapseButton.setVisibility(View.VISIBLE);
if (status.isCollapsed()) {
contentCollapseButton.setText(R.string.status_content_warning_show_more);
contentCollapseButton.setText(R.string.post_content_warning_show_more);
content.setFilters(COLLAPSE_INPUT_FILTER);
} else {
contentCollapseButton.setText(R.string.status_content_warning_show_less);
contentCollapseButton.setText(R.string.post_content_warning_show_less);
content.setFilters(NO_INPUT_FILTER);
}
} else {

View file

@ -3,9 +3,7 @@ package com.keylesspalace.tusky.appstore
import com.google.gson.Gson
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import javax.inject.Inject
class CacheUpdater @Inject constructor(
@ -47,12 +45,7 @@ class CacheUpdater @Inject constructor(
this.disposable.dispose()
}
fun clearForUser(accountId: Long) {
Single.fromCallable {
appDatabase.timelineDao().removeAllForAccount(accountId)
appDatabase.timelineDao().removeAllUsersForAccount(accountId)
}
.subscribeOn(Schedulers.io())
.subscribe()
suspend fun clearForUser(accountId: Long) {
appDatabase.timelineDao().removeAll(accountId)
}
}

View file

@ -55,27 +55,31 @@ import com.keylesspalace.tusky.AccountListActivity
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.EditProfileActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.report.ReportActivity
import com.keylesspalace.tusky.databinding.ActivityAccountBinding
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.DefaultTextWatcher
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.getDomain
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
@ -230,7 +234,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]
@ -402,12 +406,12 @@ 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)
val emojifiedNote = account.note.emojify(account.emojis, binding.accountNoteTextView, animateEmojis)
LinkHelper.setClickableText(binding.accountNoteTextView, emojifiedNote, null, this)
setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this)
// accountFieldAdapter.fields = account.fields ?: emptyList()
accountFieldAdapter.emojis = account.emojis ?: emptyList()
@ -473,7 +477,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)
}
}
@ -490,7 +494,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)
@ -515,7 +519,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
if (account.isRemote()) {
binding.accountRemoveView.show()
binding.accountRemoveView.setOnClickListener {
LinkHelper.openLink(account.url, this)
openLink(account.url)
}
}
}
@ -686,6 +690,14 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.account_toolbar, menu)
val openAsItem = menu.findItem(R.id.action_open_as)
val title = openAsText
if (title == null) {
openAsItem.isVisible = false
} else {
openAsItem.title = title
}
if (!viewModel.isSelf) {
val block = menu.findItem(R.id.action_block)
@ -704,7 +716,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
if (loadedAccount != null) {
val muteDomain = menu.findItem(R.id.action_mute_domain)
domain = LinkHelper.getDomain(loadedAccount?.url)
domain = getDomain(loadedAccount?.url)
if (domain.isEmpty()) {
// If we can't get the domain, there's no way we can mute it anyway...
menu.removeItem(R.id.action_mute_domain)
@ -805,8 +817,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
}
override fun onViewTag(tag: String) {
val intent = Intent(this, ViewTagActivity::class.java)
intent.putExtra("hashtag", tag)
val intent = StatusListActivity.newHashtagIntent(this, tag)
startActivityWithSlideInAnimation(intent)
}
@ -824,11 +835,23 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
when (item.itemId) {
R.id.action_open_in_web -> {
// If the account isn't loaded yet, eat the input.
if (loadedAccount != null) {
LinkHelper.openLink(loadedAccount?.url, this)
if (loadedAccount?.url != null) {
openLink(loadedAccount!!.url)
}
return true
}
R.id.action_open_as -> {
if (loadedAccount != null) {
showAccountChooserDialog(
item.title, false,
object : AccountSelectionListener {
override fun onAccountSelected(account: AccountEntity) {
openAsAccount(loadedAccount!!.url, account)
}
}
)
}
}
R.id.action_block -> {
toggleBlock()
return true

View file

@ -27,8 +27,9 @@ import com.keylesspalace.tusky.entity.IdentityProof
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.createClickableText
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.setClickableText
class AccountFieldAdapter(
private val linkListener: LinkListener,
@ -54,7 +55,7 @@ class AccountFieldAdapter(
val identityProof = proofOrField.asLeft()
nameTextView.text = identityProof.provider
valueTextView.text = LinkHelper.createClickableText(identityProof.username, identityProof.profileUrl)
valueTextView.text = createClickableText(identityProof.username, identityProof.profileUrl)
valueTextView.movementMethod = LinkMovementMethod.getInstance()
@ -65,7 +66,7 @@ class AccountFieldAdapter(
nameTextView.text = emojifiedName
val emojifiedValue = field.value.emojify(emojis, valueTextView, animateEmojis)
LinkHelper.setClickableText(valueTextView, emojifiedValue, null, linkListener)
setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener)
if (field.verifiedAt != null) {
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)

View file

@ -37,9 +37,9 @@ import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.RefreshableFragment
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.SquareImageView
@ -252,7 +252,7 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr
}
}
Attachment.Type.UNKNOWN -> {
LinkHelper.openLink(items[currentIndex].attachment.url, context)
context?.openLink(items[currentIndex].attachment.url)
}
}
}

View file

@ -31,8 +31,8 @@ import com.keylesspalace.tusky.entity.Announcement
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.EmojiSpan
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.setClickableText
import java.lang.ref.WeakReference
interface AnnouncementActionListener : LinkListener {
@ -62,7 +62,7 @@ class AnnouncementAdapter(
val emojifiedText: CharSequence = item.content.emojify(item.emojis, text, animateEmojis)
LinkHelper.setClickableText(text, emojifiedText, item.mentions, listener)
setClickableText(text, emojifiedText, item.mentions, item.tags, listener)
// If wellbeing mode is enabled, announcement badge counts should not be shown.
if (wellbeingEnabled) {

View file

@ -27,7 +27,7 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.adapter.EmojiAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.databinding.ActivityAnnouncementsBinding
@ -152,22 +152,17 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
viewModel.removeReaction(announcementId, name)
}
override fun onViewTag(tag: String?) {
val intent = Intent(this, ViewTagActivity::class.java)
intent.putExtra("hashtag", tag)
override fun onViewTag(tag: String) {
val intent = StatusListActivity.newHashtagIntent(this, tag)
startActivityWithSlideInAnimation(intent)
}
override fun onViewAccount(id: String?) {
if (id != null) {
viewAccount(id)
}
override fun onViewAccount(id: String) {
viewAccount(id)
}
override fun onViewUrl(url: String?) {
if (url != null) {
viewUrl(url)
}
override fun onViewUrl(url: String) {
viewUrl(url)
}
companion object {

View file

@ -20,6 +20,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.components.compose.DEFAULT_MAXIMUM_URL_LENGTH
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.InstanceEntity
@ -57,19 +58,21 @@ class AnnouncementsViewModel @Inject constructor(
.onErrorResumeNext {
mastodonApi.getInstance()
.map { Either.Right(it) }
},
{ emojis, either ->
either.asLeftOrNull()?.copy(emojiList = emojis)
?: InstanceEntity(
accountManager.activeAccount?.domain!!,
emojis,
either.asRight().maxTootChars,
either.asRight().pollLimits?.maxOptions,
either.asRight().pollLimits?.maxOptionChars,
either.asRight().version
)
}
)
}
) { emojis, either ->
either.asLeftOrNull()?.copy(emojiList = emojis)
?: InstanceEntity(
accountManager.activeAccount?.domain!!,
emojis,
either.asRight().configuration?.statuses?.maxCharacters ?: either.asRight().maxTootChars,
either.asRight().configuration?.polls?.maxOptions ?: either.asRight().pollConfiguration?.maxOptions,
either.asRight().configuration?.polls?.maxCharactersPerOption ?: either.asRight().pollConfiguration?.maxOptionChars,
either.asRight().configuration?.polls?.minExpiration ?: either.asRight().pollConfiguration?.minExpiration,
either.asRight().configuration?.polls?.maxExpiration ?: either.asRight().pollConfiguration?.maxExpiration,
either.asRight().configuration?.statuses?.charactersReservedPerUrl ?: DEFAULT_MAXIMUM_URL_LENGTH,
either.asRight().version
)
}
.doOnSuccess {
appDatabase.instanceDao().insertOrReplace(it)
}

View file

@ -16,7 +16,9 @@
package com.keylesspalace.tusky.components.compose
import android.Manifest
import android.app.NotificationManager
import android.app.ProgressDialog
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
@ -45,8 +47,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.preference.PreferenceManager
@ -105,7 +107,7 @@ class ComposeActivity :
ComposeAutoCompleteAdapter.AutocompletionProvider,
OnEmojiSelectedListener,
Injectable,
InputConnectionCompat.OnCommitContentListener,
OnReceiveContentListener,
ComposeScheduleView.OnTimeSetListener {
@Inject
@ -122,6 +124,7 @@ class ComposeActivity :
@VisibleForTesting
var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT
var charactersReservedPerUrl = DEFAULT_MAXIMUM_URL_LENGTH
private val viewModel: ComposeViewModel by viewModels { viewModelFactory }
@ -148,6 +151,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") {
@ -186,9 +201,9 @@ class ComposeActivity :
viewModel.setup(composeOptions)
setupReplyViews(composeOptions?.replyingStatusAuthor, composeOptions?.replyingStatusContent)
val tootText = composeOptions?.tootText
if (!tootText.isNullOrEmpty()) {
binding.composeEditField.setText(tootText)
val statusContent = composeOptions?.content
if (!statusContent.isNullOrEmpty()) {
binding.composeEditField.setText(statusContent)
}
if (!composeOptions?.scheduledAt.isNullOrEmpty()) {
@ -221,26 +236,25 @@ class ComposeActivity :
}
}
}
} else if (type == "text/plain" && intent.action == Intent.ACTION_SEND) {
}
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
val text = intent.getStringExtra(Intent.EXTRA_TEXT).orEmpty()
val shareBody = if (!subject.isNullOrBlank() && subject !in text) {
subject + '\n' + text
} else {
text
}
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
val text = intent.getStringExtra(Intent.EXTRA_TEXT).orEmpty()
val shareBody = if (!subject.isNullOrBlank() && subject !in text) {
subject + '\n' + text
} else {
text
}
if (shareBody.isNotBlank()) {
val start = binding.composeEditField.selectionStart.coerceAtLeast(0)
val end = binding.composeEditField.selectionEnd.coerceAtLeast(0)
val left = min(start, end)
val right = max(start, end)
binding.composeEditField.text.replace(left, right, shareBody, 0, shareBody.length)
// move edittext cursor to first when shareBody parsed
binding.composeEditField.text.insert(0, "\n")
binding.composeEditField.setSelection(0)
}
if (shareBody.isNotBlank()) {
val start = binding.composeEditField.selectionStart.coerceAtLeast(0)
val end = binding.composeEditField.selectionEnd.coerceAtLeast(0)
val left = min(start, end)
val right = max(start, end)
binding.composeEditField.text.replace(left, right, shareBody, 0, shareBody.length)
// move edittext cursor to first when shareBody parsed
binding.composeEditField.text.insert(0, "\n")
binding.composeEditField.setSelection(0)
}
}
}
@ -281,7 +295,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) }
@ -316,6 +330,7 @@ class ComposeActivity :
withLifecycleContext {
viewModel.instanceParams.observe { instanceData ->
maximumTootCharacters = instanceData.maxChars
charactersReservedPerUrl = instanceData.charactersReservedPerUrl
updateVisibleCharactersLeft()
binding.composeScheduleButton.visible(instanceData.supportsScheduled)
}
@ -654,7 +669,8 @@ class ComposeActivity :
val instanceParams = viewModel.instanceParams.value!!
showAddPollDialog(
this, viewModel.poll.value, instanceParams.pollMaxOptions,
instanceParams.pollMaxLength, viewModel::updatePoll
instanceParams.pollMaxLength, instanceParams.pollMinDuration, instanceParams.pollMaxDuration,
viewModel::updatePoll
)
}
@ -699,7 +715,9 @@ class ComposeActivity :
val urlSpans = binding.composeEditField.urls
if (urlSpans != null) {
for (span in urlSpans) {
offset += max(0, span.url.length - MAXIMUM_URL_LENGTH)
// 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
@ -739,26 +757,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() {
@ -781,12 +791,11 @@ class ComposeActivity :
}
viewModel.sendStatus(contentText, spoilerText).observe(
this,
{
finishingUploadDialog?.dismiss()
deleteDraftAndFinish()
}
)
this
) {
finishingUploadDialog?.dismiss()
deleteDraftAndFinish()
}
} else {
binding.composeEditField.error = getString(R.string.error_compose_character_limit)
enableButtons(true)
@ -856,12 +865,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 -> {
@ -1017,7 +1023,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,
@ -1040,16 +1046,32 @@ 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"
// Mastodon only counts URLs as this long in terms of status character limits
@VisibleForTesting
const val MAXIMUM_URL_LENGTH = 23
/**
* @param options ComposeOptions to configure the ComposeActivity
* @param notificationId the id of the notification that starts the Activity
* @param accountId the id of the account to compose with, null for the current account
* @return an Intent to start the ComposeActivity
*/
@JvmStatic
fun startIntent(context: Context, options: ComposeOptions): Intent {
@JvmOverloads
fun startIntent(
context: Context,
options: ComposeOptions,
notificationId: Int? = null,
accountId: Long? = null
): Intent {
return Intent(context, ComposeActivity::class.java).apply {
putExtra(COMPOSE_OPTIONS_EXTRA, options)
if (notificationId != null) {
putExtra(NOTIFICATION_ID_EXTRA, notificationId)
}
if (accountId != null) {
putExtra(ACCOUNT_ID_EXTRA, accountId)
}
}
}

View file

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

View file

@ -34,7 +34,7 @@ import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.TootToSend
import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.VersionUtils
@ -79,6 +79,9 @@ class ComposeViewModel @Inject constructor(
maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
pollMinDuration = instance?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION,
pollMaxDuration = instance?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION,
charactersReservedPerUrl = instance?.charactersReservedPerUrl ?: DEFAULT_MAXIMUM_URL_LENGTH,
supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false
)
}
@ -102,18 +105,20 @@ class ComposeViewModel @Inject constructor(
init {
Single.zip(
api.getCustomEmojis(), api.getInstance(),
{ emojis, instance ->
InstanceEntity(
instance = accountManager.activeAccount?.domain!!,
emojiList = emojis,
maximumTootCharacters = instance.maxTootChars,
maxPollOptions = instance.pollLimits?.maxOptions,
maxPollOptionLength = instance.pollLimits?.maxOptionChars,
version = instance.version
)
}
)
api.getCustomEmojis(), api.getInstance()
) { emojis, instance ->
InstanceEntity(
instance = accountManager.activeAccount?.domain!!,
emojiList = emojis,
maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: instance.maxTootChars,
maxPollOptions = instance.configuration?.polls?.maxOptions ?: instance.pollConfiguration?.maxOptions,
maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: instance.pollConfiguration?.maxOptionChars,
minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration,
maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration,
charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl,
version = instance.version
)
}
.doOnSuccess {
db.instanceDao().insertOrReplace(it)
}
@ -185,7 +190,7 @@ class ComposeViewModel @Inject constructor(
is UploadEvent.ProgressEvent ->
item.copy(uploadPercent = event.percentage)
is UploadEvent.FinishedEvent ->
item.copy(id = event.attachment.id, uploadPercent = -1)
item.copy(id = event.mediaId, uploadPercent = -1)
}
synchronized(media) {
val mediaValue = media.value!!
@ -303,7 +308,7 @@ class ComposeViewModel @Inject constructor(
mediaDescriptions.add(item.description ?: "")
}
val tootToSend = TootToSend(
val tootToSend = StatusToSend(
text = content,
warningText = spoilerText,
visibility = statusVisibility.value!!.serverString(),
@ -451,7 +456,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) {
@ -506,11 +511,19 @@ fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = defau
const val DEFAULT_CHARACTER_LIMIT = 500
private const val DEFAULT_MAX_OPTION_COUNT = 4
private const val DEFAULT_MAX_OPTION_LENGTH = 50
private const val DEFAULT_MIN_POLL_DURATION = 300
private const val DEFAULT_MAX_POLL_DURATION = 604800
// Mastodon only counts URLs as this long in terms of status character limits
const val DEFAULT_MAXIMUM_URL_LENGTH = 23
data class ComposeInstanceParams(
val maxChars: Int,
val pollMaxOptions: Int,
val pollMaxLength: Int,
val pollMinDuration: Int,
val pollMaxDuration: Int,
val charactersReservedPerUrl: Int,
val supportsScheduled: Boolean
)

View file

@ -15,6 +15,7 @@
package com.keylesspalace.tusky.components.compose
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Environment
@ -25,7 +26,6 @@ import androidx.core.net.toUri
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.ProgressRequestBody
import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN
@ -38,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
@ -45,7 +46,7 @@ import javax.inject.Inject
sealed class UploadEvent {
data class ProgressEvent(val percentage: Int) : UploadEvent()
data class FinishedEvent(val attachment: Attachment) : UploadEvent()
data class FinishedEvent(val mediaId: String) : UploadEvent()
}
fun createNewImageFile(context: Context): File {
@ -84,36 +85,70 @@ class MediaUploader @Inject constructor(
fun prepareMedia(inUri: Uri): Single<PreparedMedia> {
return Single.fromCallable {
var mediaSize = getMediaSize(contentResolver, inUri)
var mediaSize = MEDIA_SIZE_UNKNOWN
var uri = inUri
val mimeType = contentResolver.getType(uri)
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
var mimeType: String? = null
try {
contentResolver.openInputStream(inUri).use { input ->
if (input == null) {
Log.w(TAG, "Media input is null")
uri = inUri
return@use
when (inUri.scheme) {
ContentResolver.SCHEME_CONTENT -> {
mimeType = contentResolver.getType(uri)
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
contentResolver.openInputStream(inUri).use { input ->
if (input == null) {
Log.w(TAG, "Media input is null")
uri = inUri
return@use
}
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
FileOutputStream(file.absoluteFile).use { out ->
input.copyTo(out)
uri = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID + ".fileprovider",
file
)
mediaSize = getMediaSize(contentResolver, uri)
}
}
}
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
FileOutputStream(file.absoluteFile).use { out ->
input.copyTo(out)
uri = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID + ".fileprovider",
file
)
mediaSize = getMediaSize(contentResolver, uri)
ContentResolver.SCHEME_FILE -> {
val path = uri.path
if (path == null) {
Log.w(TAG, "empty uri path $uri")
throw CouldNotOpenFileException()
}
val inputFile = File(path)
val suffix = inputFile.name.substringAfterLast('.', "tmp")
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix)
val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir)
val input = FileInputStream(inputFile)
FileOutputStream(file.absoluteFile).use { out ->
input.copyTo(out)
uri = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID + ".fileprovider",
file
)
mediaSize = getMediaSize(contentResolver, uri)
}
}
else -> {
Log.w(TAG, "Unknown uri scheme $uri")
throw CouldNotOpenFileException()
}
}
} catch (e: IOException) {
Log.w(TAG, e)
uri = inUri
throw CouldNotOpenFileException()
}
if (mediaSize == MEDIA_SIZE_UNKNOWN) {
throw CouldNotOpenFileException()
Log.w(TAG, "Could not determine file size of upload")
throw MediaTypeException()
}
if (mimeType != null) {
@ -139,6 +174,7 @@ class MediaUploader @Inject constructor(
}
}
} else {
Log.w(TAG, "Could not determine mime type of upload")
throw MediaTypeException()
}
}
@ -183,8 +219,8 @@ class MediaUploader @Inject constructor(
val uploadDisposable = mastodonApi.uploadMedia(body, description)
.subscribe(
{ attachment ->
emitter.onNext(UploadEvent.FinishedEvent(attachment))
{ result ->
emitter.onNext(UploadEvent.FinishedEvent(result.id))
emitter.onComplete()
},
{ e ->

View file

@ -20,6 +20,7 @@ package com.keylesspalace.tusky.components.compose.dialog
import android.content.Context
import android.view.LayoutInflater
import android.view.WindowManager
import android.widget.ArrayAdapter
import androidx.appcompat.app.AlertDialog
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.DialogAddPollBinding
@ -30,6 +31,8 @@ fun showAddPollDialog(
poll: NewPoll?,
maxOptionCount: Int,
maxOptionLength: Int,
minDuration: Int,
maxDuration: Int,
onUpdatePoll: (NewPoll) -> Unit
) {
@ -57,6 +60,13 @@ fun showAddPollDialog(
binding.pollChoices.adapter = adapter
var durations = context.resources.getIntArray(R.array.poll_duration_values).toList()
val durationLabels = context.resources.getStringArray(R.array.poll_duration_names).filterIndexed { index, _ -> durations[index] in minDuration..maxDuration }
binding.pollDurationSpinner.adapter = ArrayAdapter(context, android.R.layout.simple_spinner_item, durationLabels).apply {
setDropDownViewResource(R.layout.support_simple_spinner_dropdown_item)
}
durations = durations.filter { it in minDuration..maxDuration }
binding.addChoiceButton.setOnClickListener {
if (adapter.itemCount < maxOptionCount) {
adapter.addChoice()
@ -66,7 +76,7 @@ fun showAddPollDialog(
}
}
val pollDurationId = context.resources.getIntArray(R.array.poll_duration_values).indexOfLast {
val pollDurationId = durations.indexOfLast {
it <= poll?.expiresIn ?: 0
}
@ -79,13 +89,10 @@ fun showAddPollDialog(
button.setOnClickListener {
val selectedPollDurationId = binding.pollDurationSpinner.selectedItemPosition
val pollDuration = context.resources
.getIntArray(R.array.poll_duration_values)[selectedPollDurationId]
onUpdatePoll(
NewPoll(
options = adapter.pollOptions,
expiresIn = pollDuration,
expiresIn = durations[selectedPollDurationId],
multiple = binding.multipleChoicesCheckBox.isChecked
)
)

View file

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

View file

@ -16,17 +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
@ -47,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 = ""
)
}
}
@ -79,6 +77,7 @@ data class ConversationStatusEntity(
val spoilerText: String,
val attachments: ArrayList<Attachment>,
val mentions: List<Status.Mention>,
val tags: List<HashTag>?,
val showingHiddenContent: Boolean,
val expanded: Boolean,
val collapsible: Boolean,
@ -98,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
@ -107,6 +106,7 @@ data class ConversationStatusEntity(
if (spoilerText != other.spoilerText) return false
if (attachments != other.attachments) return false
if (mentions != other.mentions) return false
if (tags != other.tags) return false
if (showingHiddenContent != other.showingHiddenContent) return false
if (expanded != other.expanded) return false
if (collapsible != other.collapsible) return false
@ -123,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
@ -132,6 +132,7 @@ data class ConversationStatusEntity(
result = 31 * result + spoilerText.hashCode()
result = 31 * result + attachments.hashCode()
result = 31 * result + mentions.hashCode()
result = 31 * result + tags.hashCode()
result = 31 * result + showingHiddenContent.hashCode()
result = 31 * result + expanded.hashCode()
result = 31 * result + collapsible.hashCode()
@ -162,6 +163,7 @@ data class ConversationStatusEntity(
visibility = Status.Visibility.DIRECT,
attachments = attachments,
mentions = mentions,
tags = tags,
application = null,
pinned = false,
muted = muted,
@ -171,7 +173,7 @@ data class ConversationStatusEntity(
}
}
fun Account.toEntity() =
fun TimelineAccount.toEntity() =
ConversationAccountEntity(
id = id,
username = username,
@ -197,6 +199,7 @@ fun Status.toEntity() =
spoilerText = spoilerText,
attachments = attachments,
mentions = mentions,
tags = tags,
showingHiddenContent = false,
expanded = false,
collapsible = shouldTrimStatus(content),

View file

@ -108,7 +108,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
statusDisplayOptions);
setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(),
status.getMentions(), status.getEmojis(),
status.getMentions(), status.getTags(), status.getEmojis(),
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);
setConversationName(conversation.getAccounts());
@ -154,10 +154,10 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
contentCollapseButton.setVisibility(View.VISIBLE);
if (collapsed) {
contentCollapseButton.setText(R.string.status_content_warning_show_more);
contentCollapseButton.setText(R.string.post_content_warning_show_more);
content.setFilters(COLLAPSE_INPUT_FILTER);
} else {
contentCollapseButton.setText(R.string.status_content_warning_show_less);
contentCollapseButton.setText(R.string.post_content_warning_show_less);
content.setFilters(NO_INPUT_FILTER);
}
} else {

View file

@ -15,7 +15,6 @@
package com.keylesspalace.tusky.components.conversation
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -31,7 +30,7 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
import com.keylesspalace.tusky.di.Injectable
@ -103,7 +102,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
initSwipeToRefresh()
lifecycleScope.launch {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.conversationFlow.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
@ -233,8 +232,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
override fun onViewTag(tag: String) {
val intent = Intent(context, ViewTagActivity::class.java)
intent.putExtra("hashtag", tag)
val intent = StatusListActivity.newHashtagIntent(requireContext(), tag)
startActivity(intent)
}

View file

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

View file

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

View file

@ -0,0 +1,309 @@
/* 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.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.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.rickRoll
import com.keylesspalace.tusky.util.shouldRickRoll
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.registerButton.setOnClickListener { onRegisterClick() }
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)
}
}
/**
* Handle registation of new account in the most basic way possible; open a URL
* in the system default browser.
*/
private fun onRegisterClick() {
binding.registerButton.isEnabled = false
val openRegisterPage = Intent(android.content.Intent.ACTION_VIEW)
openRegisterPage.data = Uri.parse(BuildConfig.REGISTER_ACCOUNT_URL)
startActivity(openRegisterPage)
}
/**
* 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
}
if (shouldRickRoll(this, domain)) {
rickRoll(this)
return
}
setLoading(true)
lifecycleScope.launch {
val credentials: AppCredentials = try {
mastodonApi.authenticateApp(
domain, getString(R.string.app_name), oauthRedirectUri,
OAUTH_SCOPES, getString(R.string.tusky_website)
)
} catch (e: Exception) {
binding.loginButton.isEnabled = true
binding.domainTextInputLayout.error =
getString(R.string.error_failed_app_registration)
setLoading(false)
Log.e(TAG, Log.getStackTraceString(e))
return@launch
}
// Before we open browser page we save the data.
// Even if we don't open other apps user may go to password manager or somewhere else
// and we will need to pick up the process where we left off.
// Alternatively we could pass it all as part of the intent and receive it back
// but it is a bit of a workaround.
preferences.edit()
.putString(DOMAIN, domain)
.putString(CLIENT_ID, credentials.clientId)
.putString(CLIENT_SECRET, credentials.clientSecret)
.apply()
redirectUserToAuthorizeAndLogin(domain, credentials.clientId)
}
}
private fun redirectUserToAuthorizeAndLogin(domain: String, clientId: String) {
// To authorize this app and log in it's necessary to redirect to the domain given,
// login there, and the server will redirect back to the app with its response.
val url = HttpUrl.Builder()
.scheme("https")
.host(domain)
.addPathSegments(MastodonApi.ENDPOINT_AUTHORIZE)
.addQueryParameter("client_id", clientId)
.addQueryParameter("redirect_uri", oauthRedirectUri)
.addQueryParameter("response_type", "code")
.addQueryParameter("scope", OAUTH_SCOPES)
.build()
doWebViewAuth.launch(LoginData(url.toString().toUri(), oauthRedirectUri.toUri()))
}
override fun onStart() {
super.onStart()
// first show or user cancelled login
setLoading(false)
}
private suspend fun fetchOauthToken(code: String) {
/* restore variables from SharedPreferences */
val domain = preferences.getNonNullString(DOMAIN, "")
val clientId = preferences.getNonNullString(CLIENT_ID, "")
val clientSecret = preferences.getNonNullString(CLIENT_SECRET, "")
setLoading(true)
val accessToken = try {
mastodonApi.fetchOAuthToken(
domain, clientId, clientSecret, oauthRedirectUri, code,
"authorization_code"
)
} catch (e: Exception) {
setLoading(false)
binding.domainTextInputLayout.error =
getString(R.string.error_retrieving_oauth_token)
Log.e(
TAG,
"%s %s".format(getString(R.string.error_retrieving_oauth_token), e.message),
)
return
}
accountManager.addAccount(accessToken.accessToken, domain)
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
finish()
overridePendingTransition(R.anim.explode, R.anim.explode)
}
private fun setLoading(loadingState: Boolean) {
if (loadingState) {
binding.loginLoadingLayout.visibility = View.VISIBLE
binding.loginInputLayout.visibility = View.GONE
} else {
binding.loginLoadingLayout.visibility = View.GONE
binding.loginInputLayout.visibility = View.VISIBLE
binding.loginButton.isEnabled = true
}
}
private fun isAdditionalLogin(): Boolean {
return intent.getBooleanExtra(LOGIN_MODE, false)
}
companion object {
private const val TAG = "LoginActivity" // logging tag
private const val OAUTH_SCOPES = "read write follow"
private const val LOGIN_MODE = "LOGIN_MODE"
private const val DOMAIN = "domain"
private const val CLIENT_ID = "clientId"
private const val CLIENT_SECRET = "clientSecret"
@JvmStatic
fun getIntent(context: Context, mode: Boolean): Intent {
val loginIntent = Intent(context, LoginActivity::class.java)
loginIntent.putExtra(LOGIN_MODE, mode)
return loginIntent
}
/** Make sure the user-entered text is just a fully-qualified domain name. */
private fun canonicalizeDomain(domain: String): String {
// Strip any schemes out.
var s = domain.replaceFirst("http://", "")
s = s.replaceFirst("https://", "")
// If a username was included (e.g. username@example.com), just take what's after the '@'.
val at = s.lastIndexOf('@')
if (at != -1) {
s = s.substring(at + 1)
}
return s.trim { it <= ' ' }
}
}
}

View file

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

View file

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

View file

@ -15,6 +15,7 @@ import androidx.preference.Preference
import androidx.preference.PreferenceManager
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
@ -220,7 +221,7 @@ class EmojiPreference(
context,
0x1f973, // This is the codepoint of the party face emoji :D
launchIntent,
PendingIntent.FLAG_CANCEL_CURRENT
NotificationHelper.pendingIntentFlags(false)
)
val mgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
mgr.set(

View file

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

View file

@ -86,11 +86,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)
}
@ -138,6 +138,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 +186,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
setTitle(R.string.pref_title_enable_swipe_for_tabs)
isSingleLineTitle = false
}
switchPreference {
setDefaultValue(false)
key = PrefKeys.ANIMATE_CUSTOM_EMOJIS
setTitle(R.string.pref_title_animate_custom_emojis)
isSingleLineTitle = false
}
}
preferenceCategory(R.string.pref_title_browser_settings) {
@ -199,7 +199,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)
@ -280,23 +280,24 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
}
private fun updateHttpProxySummary() {
val sharedPreferences = preferenceManager.sharedPreferences
val httpProxyEnabled = sharedPreferences.getBoolean(PrefKeys.HTTP_PROXY_ENABLED, false)
val httpServer = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_SERVER, "")
preferenceManager.sharedPreferences?.let { sharedPreferences ->
val httpProxyEnabled = sharedPreferences.getBoolean(PrefKeys.HTTP_PROXY_ENABLED, false)
val httpServer = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_SERVER, "")
try {
val httpPort = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_PORT, "-1")
.toInt()
try {
val httpPort = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_PORT, "-1")
.toInt()
if (httpProxyEnabled && httpServer.isNotBlank() && httpPort > 0 && httpPort < 65535) {
httpProxyPref?.summary = "$httpServer:$httpPort"
return
if (httpProxyEnabled && httpServer.isNotBlank() && httpPort > 0 && httpPort < 65535) {
httpProxyPref?.summary = "$httpServer:$httpPort"
return
}
} catch (e: NumberFormatException) {
// user has entered wrong port, fall back to empty summary
}
} catch (e: NumberFormatException) {
// user has entered wrong port, fall back to empty summary
}
httpProxyPref?.summary = ""
httpProxyPref?.summary = ""
}
}
companion object {

View file

@ -23,9 +23,9 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.report.model.StatusViewState
import com.keylesspalace.tusky.databinding.ItemReportStatusBinding
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.StatusViewHelper
import com.keylesspalace.tusky.util.StatusViewHelper.Companion.COLLAPSE_INPUT_FILTER
@ -33,6 +33,8 @@ import com.keylesspalace.tusky.util.StatusViewHelper.Companion.NO_INPUT_FILTER
import com.keylesspalace.tusky.util.TimestampUtils
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.setClickableMentions
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.shouldTrimStatus
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.viewdata.toViewData
@ -96,7 +98,7 @@ class StatusViewHolder(
)
if (status.spoilerText.isBlank()) {
setTextVisible(true, status.content, status.mentions, status.emojis, adapterHandler)
setTextVisible(true, status.content, status.mentions, status.tags, status.emojis, adapterHandler)
binding.statusContentWarningButton.hide()
binding.statusContentWarningDescription.hide()
} else {
@ -110,35 +112,36 @@ class StatusViewHolder(
val contentShown = viewState.isContentShow(status.id, true)
binding.statusContentWarningDescription.invalidate()
viewState.setContentShow(status.id, !contentShown)
setTextVisible(!contentShown, status.content, status.mentions, status.emojis, adapterHandler)
setTextVisible(!contentShown, status.content, status.mentions, status.tags, status.emojis, adapterHandler)
setContentWarningButtonText(!contentShown)
}
}
setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.emojis, adapterHandler)
setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.tags, status.emojis, adapterHandler)
}
}
}
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)
}
}
private fun setTextVisible(
expanded: Boolean,
content: Spanned,
mentions: List<Status.Mention>?,
mentions: List<Status.Mention>,
tags: List<HashTag>?,
emojis: List<Emoji>,
listener: LinkListener
) {
if (expanded) {
val emojifiedText = content.emojify(emojis, binding.statusContent, statusDisplayOptions.animateEmojis)
LinkHelper.setClickableText(binding.statusContent, emojifiedText, mentions, listener)
setClickableText(binding.statusContent, emojifiedText, mentions, tags, listener)
} else {
LinkHelper.setClickableMentions(binding.statusContent, mentions, listener)
setClickableMentions(binding.statusContent, mentions, listener)
}
if (binding.statusContent.text.isNullOrBlank()) {
binding.statusContent.hide()
@ -174,10 +177,10 @@ class StatusViewHolder(
binding.buttonToggleContent.show()
if (collapsed) {
binding.buttonToggleContent.setText(R.string.status_content_show_more)
binding.buttonToggleContent.setText(R.string.post_content_show_more)
binding.statusContent.filters = COLLAPSE_INPUT_FILTER
} else {
binding.buttonToggleContent.setText(R.string.status_content_show_less)
binding.buttonToggleContent.setText(R.string.post_content_show_less)
binding.statusContent.filters = NO_INPUT_FILTER
}
} else {

View file

@ -29,8 +29,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.report.ReportViewModel
import com.keylesspalace.tusky.components.report.Screen
@ -152,7 +152,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()
}
@ -180,9 +180,9 @@ class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Inje
override fun onViewAccount(id: String) = startActivity(AccountActivity.getIntent(requireContext(), id))
override fun onViewTag(tag: String) = startActivity(ViewTagActivity.getIntent(requireContext(), tag))
override fun onViewTag(tag: String) = startActivity(StatusListActivity.newHashtagIntent(requireContext(), tag))
override fun onViewUrl(url: String?) = viewModel.checkClickedUrl(url)
override fun onViewUrl(url: String) = viewModel.checkClickedUrl(url)
companion object {
fun newInstance() = ReportStatusesFragment()

View file

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

View file

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

View file

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

View file

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

View file

@ -86,7 +86,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)
else -> throw IllegalArgumentException("Unknown page index: $position")

View file

@ -53,17 +53,15 @@ class SearchViewModel @Inject constructor(
val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false
val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false
private val loadedStatuses: MutableList<Pair<Status, StatusViewData.Concrete>> = mutableListOf()
private val loadedStatuses: MutableList<StatusViewData.Concrete> = mutableListOf()
private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) {
it.statuses.map { status ->
val statusViewData = status.toViewData(
status.toViewData(
isShowingContent = alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
isExpanded = alwaysOpenSpoiler,
isCollapsed = true
)
Pair(status, statusViewData)
}.apply {
loadedStatuses.addAll(this)
}
@ -100,11 +98,11 @@ class SearchViewModel @Inject constructor(
hashtagsPagingSourceFactory.newSearch(query)
}
fun removeItem(status: Pair<Status, StatusViewData.Concrete>) {
timelineCases.delete(status.first.id)
fun removeItem(statusViewData: StatusViewData.Concrete) {
timelineCases.delete(statusViewData.id)
.subscribe(
{
if (loadedStatuses.remove(status))
if (loadedStatuses.remove(statusViewData))
statusesPagingSourceFactory.invalidate()
},
{ err ->
@ -114,82 +112,81 @@ class SearchViewModel @Inject constructor(
.autoDispose()
}
fun expandedChange(status: Pair<Status, StatusViewData.Concrete>, expanded: Boolean) {
val idx = loadedStatuses.indexOf(status)
fun expandedChange(statusViewData: StatusViewData.Concrete, expanded: Boolean) {
val idx = loadedStatuses.indexOf(statusViewData)
if (idx >= 0) {
loadedStatuses[idx] = Pair(status.first, status.second.copy(isExpanded = expanded))
loadedStatuses[idx] = statusViewData.copy(isExpanded = expanded)
statusesPagingSourceFactory.invalidate()
}
}
fun reblog(status: Pair<Status, StatusViewData.Concrete>, reblog: Boolean) {
timelineCases.reblog(status.first.id, reblog)
fun reblog(statusViewData: StatusViewData.Concrete, reblog: Boolean) {
timelineCases.reblog(statusViewData.id, reblog)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ setRebloggedForStatus(status, reblog) },
{ t -> Log.d(TAG, "Failed to reblog status ${status.first.id}", t) }
{ setRebloggedForStatus(statusViewData, reblog) },
{ t -> Log.d(TAG, "Failed to reblog status ${statusViewData.id}", t) }
)
.autoDispose()
}
private fun setRebloggedForStatus(status: Pair<Status, StatusViewData.Concrete>, reblog: Boolean) {
status.first.reblogged = reblog
status.first.reblog?.reblogged = reblog
private fun setRebloggedForStatus(statusViewData: StatusViewData.Concrete, reblog: Boolean) {
statusViewData.status.reblogged = reblog
statusViewData.status.reblog?.reblogged = reblog
statusesPagingSourceFactory.invalidate()
}
fun contentHiddenChange(status: Pair<Status, StatusViewData.Concrete>, isShowing: Boolean) {
val idx = loadedStatuses.indexOf(status)
fun contentHiddenChange(statusViewData: StatusViewData.Concrete, isShowing: Boolean) {
val idx = loadedStatuses.indexOf(statusViewData)
if (idx >= 0) {
loadedStatuses[idx] = Pair(status.first, status.second.copy(isShowingContent = isShowing))
loadedStatuses[idx] = statusViewData.copy(isShowingContent = isShowing)
statusesPagingSourceFactory.invalidate()
}
}
fun collapsedChange(status: Pair<Status, StatusViewData.Concrete>, collapsed: Boolean) {
val idx = loadedStatuses.indexOf(status)
fun collapsedChange(statusViewData: StatusViewData.Concrete, collapsed: Boolean) {
val idx = loadedStatuses.indexOf(statusViewData)
if (idx >= 0) {
loadedStatuses[idx] = Pair(status.first, status.second.copy(isCollapsed = collapsed))
loadedStatuses[idx] = statusViewData.copy(isCollapsed = collapsed)
statusesPagingSourceFactory.invalidate()
}
}
fun voteInPoll(status: Pair<Status, StatusViewData.Concrete>, choices: MutableList<Int>) {
val votedPoll = status.first.actionableStatus.poll!!.votedCopy(choices)
updateStatus(status, votedPoll)
timelineCases.voteInPoll(status.first.id, votedPoll.id, choices)
fun voteInPoll(statusViewData: StatusViewData.Concrete, choices: MutableList<Int>) {
val votedPoll = statusViewData.status.actionableStatus.poll!!.votedCopy(choices)
updateStatus(statusViewData, votedPoll)
timelineCases.voteInPoll(statusViewData.id, votedPoll.id, choices)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ newPoll -> updateStatus(status, newPoll) },
{ t -> Log.d(TAG, "Failed to vote in poll: ${status.first.id}", t) }
{ newPoll -> updateStatus(statusViewData, newPoll) },
{ t -> Log.d(TAG, "Failed to vote in poll: ${statusViewData.id}", t) }
)
.autoDispose()
}
private fun updateStatus(status: Pair<Status, StatusViewData.Concrete>, newPoll: Poll) {
val idx = loadedStatuses.indexOf(status)
private fun updateStatus(statusViewData: StatusViewData.Concrete, newPoll: Poll) {
val idx = loadedStatuses.indexOf(statusViewData)
if (idx >= 0) {
val newStatus = status.first.copy(poll = newPoll)
val newViewData = status.second.copy(status = newStatus)
loadedStatuses[idx] = Pair(newStatus, newViewData)
val newStatus = statusViewData.status.copy(poll = newPoll)
loadedStatuses[idx] = statusViewData.copy(status = newStatus)
statusesPagingSourceFactory.invalidate()
}
}
fun favorite(status: Pair<Status, StatusViewData.Concrete>, isFavorited: Boolean) {
status.first.favourited = isFavorited
fun favorite(statusViewData: StatusViewData.Concrete, isFavorited: Boolean) {
statusViewData.status.favourited = isFavorited
statusesPagingSourceFactory.invalidate()
timelineCases.favourite(status.first.id, isFavorited)
.onErrorReturnItem(status.first)
timelineCases.favourite(statusViewData.id, isFavorited)
.onErrorReturnItem(statusViewData.status)
.subscribe()
.autoDispose()
}
fun bookmark(status: Pair<Status, StatusViewData.Concrete>, isBookmarked: Boolean) {
status.first.bookmarked = isBookmarked
fun bookmark(statusViewData: StatusViewData.Concrete, isBookmarked: Boolean) {
statusViewData.status.bookmarked = isBookmarked
statusesPagingSourceFactory.invalidate()
timelineCases.bookmark(status.first.id, isBookmarked)
.onErrorReturnItem(status.first)
timelineCases.bookmark(statusViewData.id, isBookmarked)
.onErrorReturnItem(statusViewData.status)
.subscribe()
.autoDispose()
}
@ -214,19 +211,15 @@ class SearchViewModel @Inject constructor(
return timelineCases.delete(id)
}
fun muteConversation(status: Pair<Status, StatusViewData.Concrete>, mute: Boolean) {
val idx = loadedStatuses.indexOf(status)
fun muteConversation(statusViewData: StatusViewData.Concrete, mute: Boolean) {
val idx = loadedStatuses.indexOf(statusViewData)
if (idx >= 0) {
val newStatus = status.first.copy(muted = mute)
val newPair = Pair(
newStatus,
status.second.copy(status = newStatus)
)
loadedStatuses[idx] = newPair
val newStatus = statusViewData.status.copy(muted = mute)
loadedStatuses[idx] = statusViewData.copy(status = newStatus)
statusesPagingSourceFactory.invalidate()
}
timelineCases.muteConversation(status.first.id, mute)
.onErrorReturnItem(status.first)
timelineCases.muteConversation(statusViewData.id, mute)
.onErrorReturnItem(statusViewData.status)
.subscribe()
.autoDispose()
}

View file

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

View file

@ -21,7 +21,6 @@ import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusViewHolder
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.StatusViewData
@ -29,7 +28,7 @@ import com.keylesspalace.tusky.viewdata.StatusViewData
class SearchStatusesAdapter(
private val statusDisplayOptions: StatusDisplayOptions,
private val statusListener: StatusActionListener
) : PagingDataAdapter<Pair<Status, StatusViewData.Concrete>, StatusViewHolder>(STATUS_COMPARATOR) {
) : PagingDataAdapter<StatusViewData.Concrete, StatusViewHolder>(STATUS_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusViewHolder {
val view = LayoutInflater.from(parent.context)
@ -39,22 +38,18 @@ class SearchStatusesAdapter(
override fun onBindViewHolder(holder: StatusViewHolder, position: Int) {
getItem(position)?.let { item ->
holder.setupWithStatus(item.second, statusListener, statusDisplayOptions)
holder.setupWithStatus(item, statusListener, statusDisplayOptions)
}
}
fun item(position: Int): Pair<Status, StatusViewData.Concrete>? {
return getItem(position)
}
companion object {
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Pair<Status, StatusViewData.Concrete>>() {
override fun areContentsTheSame(oldItem: Pair<Status, StatusViewData.Concrete>, newItem: Pair<Status, StatusViewData.Concrete>): Boolean =
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<StatusViewData.Concrete>() {
override fun areContentsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean =
oldItem == newItem
override fun areItemsTheSame(oldItem: Pair<Status, StatusViewData.Concrete>, newItem: Pair<Status, StatusViewData.Concrete>): Boolean =
oldItem.second.id == newItem.second.id
override fun areItemsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean =
oldItem.id == newItem.id
}
}
}

View file

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

View file

@ -15,7 +15,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.search.SearchViewModel
import com.keylesspalace.tusky.databinding.FragmentSearchBinding
@ -113,7 +113,7 @@ abstract class SearchFragment<T : Any> :
override fun onViewAccount(id: String) = startActivity(AccountActivity.getIntent(requireContext(), id))
override fun onViewTag(tag: String) = startActivity(ViewTagActivity.getIntent(requireContext(), tag))
override fun onViewTag(tag: String) = startActivity(StatusListActivity.newHashtagIntent(requireContext(), tag))
override fun onViewUrl(url: String) {
bottomSheetActivity?.viewUrl(url)

View file

@ -40,7 +40,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
@ -55,23 +54,23 @@ import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.view.showMuteAccountDialog
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.flow.Flow
class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concrete>>(), StatusActionListener {
class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), StatusActionListener {
override val data: Flow<PagingData<Pair<Status, StatusViewData.Concrete>>>
override val data: Flow<PagingData<StatusViewData.Concrete>>
get() = viewModel.statusesFlow
private val searchAdapter
get() = super.adapter as SearchStatusesAdapter
override fun createAdapter(): PagingDataAdapter<Pair<Status, StatusViewData.Concrete>, *> {
override fun createAdapter(): PagingDataAdapter<StatusViewData.Concrete, *> {
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
@ -92,37 +91,37 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
searchAdapter.item(position)?.let {
searchAdapter.peek(position)?.let {
viewModel.contentHiddenChange(it, isShowing)
}
}
override fun onReply(position: Int) {
searchAdapter.item(position)?.first?.let { status ->
searchAdapter.peek(position)?.status?.let { status ->
reply(status)
}
}
override fun onFavourite(favourite: Boolean, position: Int) {
searchAdapter.item(position)?.let { status ->
searchAdapter.peek(position)?.let { status ->
viewModel.favorite(status, favourite)
}
}
override fun onBookmark(bookmark: Boolean, position: Int) {
searchAdapter.item(position)?.let { status ->
searchAdapter.peek(position)?.let { status ->
viewModel.bookmark(status, bookmark)
}
}
override fun onMore(view: View, position: Int) {
searchAdapter.item(position)?.first?.let {
searchAdapter.peek(position)?.status?.let {
more(it, view, position)
}
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
searchAdapter.item(position)?.first?.actionableStatus?.let { actionable ->
searchAdapter.peek(position)?.status?.actionableStatus?.let { actionable ->
when (actionable.attachments[attachmentIndex].type) {
Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> {
val attachments = AttachmentViewData.list(actionable)
@ -143,27 +142,27 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
}
}
Attachment.Type.UNKNOWN -> {
LinkHelper.openLink(actionable.attachments[attachmentIndex].url, context)
context?.openLink(actionable.attachments[attachmentIndex].url)
}
}
}
}
override fun onViewThread(position: Int) {
searchAdapter.item(position)?.first?.let { status ->
searchAdapter.peek(position)?.status?.let { status ->
val actionableStatus = status.actionableStatus
bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url)
}
}
override fun onOpenReblog(position: Int) {
searchAdapter.item(position)?.first?.let { status ->
searchAdapter.peek(position)?.status?.let { status ->
bottomSheetActivity?.viewAccount(status.account.id)
}
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
searchAdapter.item(position)?.let {
searchAdapter.peek(position)?.let {
viewModel.expandedChange(it, expanded)
}
}
@ -173,25 +172,25 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
searchAdapter.item(position)?.let {
searchAdapter.peek(position)?.let {
viewModel.collapsedChange(it, isCollapsed)
}
}
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
searchAdapter.item(position)?.let {
searchAdapter.peek(position)?.let {
viewModel.voteInPoll(it, choices)
}
}
private fun removeItem(position: Int) {
searchAdapter.item(position)?.let {
searchAdapter.peek(position)?.let {
viewModel.removeItem(it)
}
}
override fun onReblog(reblog: Boolean, position: Int) {
searchAdapter.item(position)?.let { status ->
searchAdapter.peek(position)?.let { status ->
viewModel.reblog(status, reblog)
}
}
@ -228,9 +227,6 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
val accountId = status.actionableStatus.account.id
val accountUsername = status.actionableStatus.account.username
val statusUrl = status.actionableStatus.url
val accounts = viewModel.getAllAccountsOrderedByActive()
var openAsTitle: String? = null
val loggedInAccountId = viewModel.activeAccount?.accountId
val popup = PopupMenu(view.context, view)
@ -261,17 +257,12 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
}
val openAsItem = popup.menu.findItem(R.id.status_open_as)
when (accounts.size) {
0, 1 -> openAsItem.isVisible = false
2 -> for (account in accounts) {
if (account !== viewModel.activeAccount) {
openAsTitle = String.format(getString(R.string.action_open_as), account.fullName)
break
}
}
else -> openAsTitle = String.format(getString(R.string.action_open_as), "")
val openAsText = bottomSheetActivity?.openAsText
if (openAsText == null) {
openAsItem.isVisible = false
} else {
openAsItem.title = openAsText
}
openAsItem.title = openAsTitle
val mutable = statusIsByCurrentUser || accountIsInMentions(viewModel.activeAccount, status.mentions)
val muteConversationItem = popup.menu.findItem(R.id.status_mute_conversation).apply {
@ -289,7 +280,7 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
popup.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.status_share_content -> {
R.id.post_share_content -> {
val statusToShare: Status = status.actionableStatus
val sendIntent = Intent()
@ -300,15 +291,15 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
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 -> {
@ -325,7 +316,7 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
return@setOnMenuItemClickListener true
}
R.id.status_mute_conversation -> {
searchAdapter.item(position)?.let { foundStatus ->
searchAdapter.peek(position)?.let { foundStatus ->
viewModel.muteConversation(foundStatus, status.muted != true)
}
return@setOnMenuItemClickListener true
@ -396,21 +387,12 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
dialogTitle, false,
object : AccountSelectionListener {
override fun onAccountSelected(account: AccountEntity) {
openAsAccount(statusUrl, account)
bottomSheetActivity?.openAsAccount(statusUrl, account)
}
}
)
}
private fun openAsAccount(statusUrl: String, account: AccountEntity) {
viewModel.activeAccount = account
val intent = Intent(context, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
intent.putExtra(MainActivity.STATUS_URL, statusUrl)
startActivity(intent)
(activity as BaseActivity).finishWithoutSlideOutAnimation()
}
private fun downloadAllMedia(status: Status) {
Toast.makeText(context, R.string.downloading_media, Toast.LENGTH_SHORT).show()
for ((_, url) in status.attachments) {
@ -442,7 +424,7 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
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)
@ -455,7 +437,7 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
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())
@ -473,7 +455,7 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
val intent = ComposeActivity.startIntent(
requireContext(),
ComposeOptions(
tootText = redraftStatus.text ?: "",
content = redraftStatus.text ?: "",
inReplyToId = redraftStatus.inReplyToId,
visibility = redraftStatus.visibility,
contentWarning = redraftStatus.spoilerText,

View file

@ -38,6 +38,7 @@ import com.keylesspalace.tusky.AccountListActivity
import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.appstore.StatusComposedEvent
@ -89,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]
}
}
@ -103,8 +104,6 @@ class TimelineFragment :
private var isSwipeToRefreshEnabled = true
private var eventRegistered = false
private var layoutManager: LinearLayoutManager? = null
private var scrollListener: RecyclerView.OnScrollListener? = null
private var hideFab = false
@ -137,7 +136,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,
@ -183,7 +182,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)
}
@ -218,43 +217,14 @@ class TimelineFragment :
}
})
lifecycleScope.launch {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.statuses.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
}
private fun setupSwipeRefreshLayout() {
binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled
binding.swipeRefreshLayout.setOnRefreshListener(this)
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
}
private fun setupRecyclerView() {
binding.recyclerView.setAccessibilityDelegateCompat(
ListStatusAccessibilityDelegate(binding.recyclerView, this) { pos ->
adapter.peek(pos)
}
)
binding.recyclerView.setHasFixedSize(true)
layoutManager = LinearLayoutManager(context)
binding.recyclerView.layoutManager = layoutManager
val divider = DividerItemDecoration(context, RecyclerView.VERTICAL)
binding.recyclerView.addItemDecoration(divider)
// CWs are expanded without animation, buttons animate itself, we don't need it basically
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.recyclerView.adapter = adapter
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
/* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't
* guaranteed to be set until then. */
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) {
@ -276,23 +246,47 @@ class TimelineFragment :
}
}
if (!eventRegistered) {
eventHub.events
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { event ->
when (event) {
is PreferenceChangedEvent -> {
onPreferenceChanged(event.preferenceKey)
}
is StatusComposedEvent -> {
val status = event.status
handleStatusComposeEvent(status)
}
eventHub.events
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { event ->
when (event) {
is PreferenceChangedEvent -> {
onPreferenceChanged(event.preferenceKey)
}
is StatusComposedEvent -> {
val status = event.status
handleStatusComposeEvent(status)
}
}
eventRegistered = true
}
}
}
private fun setupSwipeRefreshLayout() {
binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled
binding.swipeRefreshLayout.setOnRefreshListener(this)
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
}
private fun setupRecyclerView() {
binding.recyclerView.setAccessibilityDelegateCompat(
ListStatusAccessibilityDelegate(binding.recyclerView, this) { pos ->
if (pos in 0 until adapter.itemCount) {
adapter.peek(pos)
} else {
null
}
}
)
binding.recyclerView.setHasFixedSize(true)
layoutManager = LinearLayoutManager(context)
binding.recyclerView.layoutManager = layoutManager
val divider = DividerItemDecoration(context, RecyclerView.VERTICAL)
binding.recyclerView.addItemDecoration(divider)
// CWs are expanded without animation, buttons animate itself, we don't need it basically
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.recyclerView.adapter = adapter
}
override fun onRefresh() {
@ -407,7 +401,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)
@ -417,7 +411,7 @@ class TimelineFragment :
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
if (enabled != oldMediaPreviewEnabled) {
adapter.mediaPreviewEnabled = enabled
adapter.notifyDataSetChanged()
adapter.notifyItemRangeChanged(0, adapter.itemCount)
}
}
}
@ -463,7 +457,7 @@ class TimelineFragment :
talkBackWasEnabled = a11yManager?.isEnabled == true
Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled")
if (talkBackWasEnabled && !wasEnabled) {
adapter.notifyDataSetChanged()
adapter.notifyItemRangeChanged(0, adapter.itemCount)
}
startUpdateTimestamp()
}
@ -474,14 +468,14 @@ 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)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_PAUSE)
.subscribe {
adapter.notifyDataSetChanged()
adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED))
}
}
}

View file

@ -41,6 +41,10 @@ class TimelinePagingAdapter(
)
}
init {
stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
}
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
VIEW_TYPE_STATUS -> {
@ -110,7 +114,7 @@ class TimelinePagingAdapter(
oldItem: StatusViewData,
newItem: StatusViewData
): Boolean {
return oldItem.viewDataId == newItem.viewDataId
return oldItem.id == newItem.id
}
override fun areContentsTheSame(
@ -124,7 +128,7 @@ class TimelinePagingAdapter(
oldItem: StatusViewData,
newItem: StatusViewData
): Any? {
return if (oldItem === newItem) {
return if (oldItem == newItem) {
// If items are equal - update timestamp only
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
} else // If items are different - update the whole view holder

View file

@ -23,11 +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
@ -41,8 +42,9 @@ data class Placeholder(
private val attachmentArrayListType = object : TypeToken<ArrayList<Attachment>>() {}.type
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,
@ -56,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)
)
}
@ -99,6 +92,7 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
visibility = Status.Visibility.UNKNOWN,
attachments = null,
mentions = null,
tags = null,
application = null,
reblogServerId = null,
reblogAccountId = null,
@ -138,6 +132,7 @@ fun Status.toEntity(
visibility = actionableStatus.visibility,
attachments = actionableStatus.attachments.let(gson::toJson),
mentions = actionableStatus.mentions.let(gson::toJson),
tags = actionableStatus.tags.let(gson::toJson),
application = actionableStatus.application.let(gson::toJson),
reblogServerId = reblog?.id,
reblogAccountId = reblog?.let { this.account.id },
@ -157,6 +152,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
val attachments: ArrayList<Attachment> = gson.fromJson(status.attachments, attachmentArrayListType) ?: arrayListOf()
val mentions: List<Status.Mention> = gson.fromJson(status.mentions, mentionListType) ?: emptyList()
val tags: List<HashTag>? = gson.fromJson(status.tags, tagListType)
val application = gson.fromJson(status.application, Status.Application::class.java)
val emojis: List<Emoji> = gson.fromJson(status.emojis, emojisListType) ?: emptyList()
val poll: Poll? = gson.fromJson(status.poll, Poll::class.java)
@ -183,6 +179,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
visibility = status.visibility,
attachments = attachments,
mentions = mentions,
tags = tags,
application = application,
pinned = false,
muted = status.muted,
@ -211,6 +208,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
visibility = status.visibility,
attachments = ArrayList(),
mentions = listOf(),
tags = listOf(),
application = null,
pinned = status.pinned,
muted = status.muted,
@ -239,6 +237,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
visibility = status.visibility,
attachments = attachments,
mentions = mentions,
tags = tags,
application = application,
pinned = status.pinned,
muted = status.muted,

View file

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

View file

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

View file

@ -34,14 +34,13 @@ 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
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
@ -70,7 +69,14 @@ class CachedTimelineViewModel @Inject constructor(
override val statuses = Pager(
config = PagingConfig(pageSize = LOAD_AT_ONCE),
remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db, gson),
pagingSourceFactory = { db.timelineDao().getStatusesForAccount(accountManager.activeAccount!!.id) }
pagingSourceFactory = {
val activeAccount = accountManager.activeAccount
if (activeAccount == null) {
EmptyTimelinePagingSource()
} else {
db.timelineDao().getStatuses(activeAccount.id)
}
}
).flow
.map { pagingData ->
pagingData.map { timelineStatus ->
@ -141,9 +147,11 @@ class CachedTimelineViewModel @Inject constructor(
timelineDao.insertStatus(Placeholder(placeholderId, loading = true).toEntity(activeAccount.id))
val nextPlaceholderId = timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId)
val response = api.homeTimeline(maxId = placeholderId.inc(), sinceId = nextPlaceholderId, limit = 20).await()
val response = db.withTransaction {
val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId)
val nextPlaceholderId = timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId)
api.homeTimeline(maxId = idAbovePlaceholder, sinceId = nextPlaceholderId, limit = LOAD_AT_ONCE)
}.await()
val statuses = response.body()
if (!response.isSuccessful || statuses == null) {
@ -177,14 +185,21 @@ class CachedTimelineViewModel @Inject constructor(
)
}
if (overlappedStatuses == 0 && statuses.isNotEmpty()) {
/* In case we loaded a whole page and there was no overlap with existing statuses,
we insert a placeholder because there might be even more unknown statuses */
if (overlappedStatuses == 0 && statuses.size == LOAD_AT_ONCE) {
/* This overrides the last of the newly loaded statuses with a placeholder
to guarantee the placeholder has an id that exists on the server as not all
servers handle client generated ids as expected */
timelineDao.insertStatus(
Placeholder(statuses.last().id.dec(), loading = false).toEntity(activeAccount.id)
Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id)
)
}
}
} catch (e: Exception) {
loadMoreFailed(placeholderId, e)
ifExpected(e) {
loadMoreFailed(placeholderId, e)
}
}
}
}
@ -214,10 +229,7 @@ class CachedTimelineViewModel @Inject constructor(
override fun fullReload() {
viewModelScope.launch {
val activeAccount = accountManager.activeAccount!!
db.runInTransaction {
db.timelineDao().removeAllForAccount(activeAccount.id)
db.timelineDao().removeAllUsersForAccount(activeAccount.id)
}
db.timelineDao().removeAll(activeAccount.id)
}
}

View file

@ -0,0 +1,11 @@
package com.keylesspalace.tusky.components.timeline.viewmodel
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
class EmptyTimelinePagingSource : PagingSource<Int, TimelineStatusWithAccount>() {
override fun getRefreshKey(state: PagingState<Int, TimelineStatusWithAccount>): Int? = null
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, TimelineStatusWithAccount> = LoadResult.Page(emptyList(), null, null)
}

View file

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

View file

@ -28,14 +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.LinkHelper
import com.keylesspalace.tusky.util.inc
import com.keylesspalace.tusky.util.getDomain
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
@ -43,6 +45,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
/**
@ -117,7 +120,7 @@ class NetworkTimelineViewModel @Inject constructor(
override fun removeAllByInstance(instance: String) {
statusData.removeAll { vd ->
val status = vd.asStatusOrNull()?.status ?: return@removeAll false
LinkHelper.getDomain(status.account.url) == instance
getDomain(status.account.url) == instance
}
currentSource?.invalidate()
}
@ -133,8 +136,14 @@ class NetworkTimelineViewModel @Inject constructor(
override fun loadMore(placeholderId: String) {
viewModelScope.launch {
try {
val placeholderIndex =
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
)
@ -145,32 +154,53 @@ class NetworkTimelineViewModel @Inject constructor(
return@launch
}
statusData.removeAt(placeholderIndex)
val activeAccount = accountManager.activeAccount!!
val data = statuses.map { status ->
val oldStatus = statusData.find { s ->
s.asStatusOrNull()?.id == status.id
}?.asStatusOrNull()
val contentShowing = oldStatus?.isShowingContent ?: activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive
val expanded = oldStatus?.isExpanded ?: activeAccount.alwaysOpenSpoiler
val contentCollapsed = oldStatus?.isCollapsed ?: true
val data: MutableList<StatusViewData> = statuses.map { status ->
status.toViewData(
isShowingContent = contentShowing,
isExpanded = expanded,
isCollapsed = contentCollapsed
isShowingContent = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
isExpanded = activeAccount.alwaysOpenSpoiler,
isCollapsed = true
)
}.toMutableList()
if (statuses.isNotEmpty()) {
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) {
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,
isExpanded = oldStatus.isExpanded,
isCollapsed = oldStatus.isCollapsed,
)
}
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 {
data[data.size - 1] = StatusViewData.Placeholder(statuses.last().id, isLoading = false)
}
}
val index =
statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId }
statusData.removeAt(index)
statusData.addAll(index, data)
statusData.addAll(placeholderIndex, data)
currentSource?.invalidate()
} catch (e: Exception) {
loadMoreFailed(placeholderId, e)
ifExpected(e) {
loadMoreFailed(placeholderId, e)
}
}
}
}
@ -210,10 +240,12 @@ class NetworkTimelineViewModel @Inject constructor(
}
override fun fullReload() {
nextKey = statusData.firstOrNull { it is StatusViewData.Concrete }?.asStatusOrNull()?.id
statusData.clear()
currentSource?.invalidate()
}
@Throws(IOException::class, HttpException::class)
suspend fun fetchStatusesForKind(
fromId: String?,
uptoId: String?,

View file

@ -33,6 +33,7 @@ import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.appstore.ReblogEvent
import com.keylesspalace.tusky.appstore.StatusDeletedEvent
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
@ -46,8 +47,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlow
import kotlinx.coroutines.rx3.await
import retrofit2.HttpException
import java.io.IOException
abstract class TimelineViewModel(
private val timelineCases: TimelineCases,
@ -291,19 +290,6 @@ abstract class TimelineViewModel(
}
}
private fun isExpectedRequestException(t: Exception) = t is IOException || t is HttpException
private inline fun ifExpected(
t: Exception,
cb: () -> Unit
) {
if (isExpectedRequestException(t)) {
cb()
} else {
throw t
}
}
companion object {
private const val TAG = "TimelineVM"
internal const val LOAD_AT_ONCE = 30

View file

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

View file

@ -27,6 +27,7 @@ import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity
import com.keylesspalace.tusky.createTabDataFromId
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
@ -119,6 +120,16 @@ class Converters @Inject constructor (
return gson.fromJson(mentionListJson, object : TypeToken<List<Status.Mention>>() {}.type)
}
@TypeConverter
fun tagListToJson(tagArray: List<HashTag>?): String? {
return gson.toJson(tagArray)
}
@TypeConverter
fun jsonToTagArray(tagListJson: String?): List<HashTag>? {
return gson.fromJson(tagListJson, object : TypeToken<List<HashTag>>() {}.type)
}
@TypeConverter
fun dateToLong(date: Date): Long {
return date.time

View file

@ -28,5 +28,8 @@ data class InstanceEntity(
val maximumTootCharacters: Int?,
val maxPollOptions: Int?,
val maxPollOptionLength: Int?,
val minPollDuration: Int?,
val maxPollDuration: Int?,
val charactersReservedPerUrl: Int?,
val version: String?
)

View file

@ -35,7 +35,7 @@ abstract class TimelineDao {
SELECT s.serverId, s.url, s.timelineUserId,
s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt,
s.emojis, s.reblogsCount, s.favouritesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive,
s.spoilerText, s.visibility, s.mentions, s.application, s.reblogServerId,s.reblogAccountId,
s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId,
s.content, s.attachments, s.poll, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned,
a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId',
a.localUsername as 'a_localUsername', a.username as 'a_username',
@ -51,7 +51,7 @@ LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND
WHERE s.timelineUserId = :account
ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC"""
)
abstract fun getStatusesForAccount(account: Long): PagingSource<Int, TimelineStatusWithAccount>
abstract fun getStatuses(account: Long): PagingSource<Int, TimelineStatusWithAccount>
@Query(
"""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND
@ -86,11 +86,20 @@ WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId =
)
abstract fun removeAllByUser(accountId: Long, userId: String)
/**
* Removes everything in the TimelineStatusEntity and TimelineAccountEntity tables for one user account
* @param accountId id of the account for which to clean tables
*/
suspend fun removeAll(accountId: Long) {
removeAllStatuses(accountId)
removeAllAccounts(accountId)
}
@Query("DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId")
abstract fun removeAllForAccount(accountId: Long)
abstract suspend fun removeAllStatuses(accountId: Long)
@Query("DELETE FROM TimelineAccountEntity WHERE timelineUserId = :accountId")
abstract fun removeAllUsersForAccount(accountId: Long)
abstract suspend fun removeAllAccounts(accountId: Long)
@Query(
"""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId
@ -98,6 +107,16 @@ AND serverId = :statusId"""
)
abstract fun delete(accountId: Long, statusId: String)
/**
* Cleans the TimelineStatusEntity and TimelineAccountEntity tables from old entries.
* @param accountId id of the account for which to clean tables
* @param limit how many statuses to keep
*/
suspend fun cleanup(accountId: Long, limit: Int) {
cleanupStatuses(accountId, limit)
cleanupAccounts(accountId)
}
/**
* Cleans the TimelineStatusEntity table from old status entries.
* @param accountId id of the account for which to clean statuses
@ -108,7 +127,7 @@ AND serverId = :statusId"""
(SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :limit)
"""
)
abstract suspend fun cleanup(accountId: Long, limit: Int)
abstract suspend fun cleanupStatuses(accountId: Long, limit: Int)
/**
* Cleans the TimelineAccountEntity table from accounts that are no longer referenced in the TimelineStatusEntity table
@ -167,6 +186,15 @@ AND timelineUserId = :accountId
@Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1")
abstract suspend fun getTopPlaceholderId(accountId: Long): String?
/**
* Returns the id directly above [serverId], or null if [serverId] is the id of the top status
*/
@Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (LENGTH(:serverId) < LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId < serverId)) ORDER BY LENGTH(serverId) ASC, serverId ASC LIMIT 1")
abstract suspend fun getIdAbove(accountId: Long, serverId: String): String?
/**
* Returns the id of the next placeholder after [serverId]
*/
@Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1")
abstract suspend fun getNextPlaceholderIdAfter(accountId: Long, serverId: String): String?
}

View file

@ -69,6 +69,7 @@ data class TimelineStatusEntity(
val visibility: Status.Visibility,
val attachments: String?,
val mentions: String?,
val tags: String?,
val application: String?,
val reblogServerId: String?, // if it has a reblogged status, it's id is stored here
val reblogAccountId: String?,

View file

@ -22,23 +22,22 @@ 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.ModalTimelineActivity
import com.keylesspalace.tusky.SplashActivity
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.TabPreferenceActivity
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.ViewThreadActivity
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.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
@ -71,12 +70,6 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesAccountListActivity(): AccountListActivity
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesModalTimelineActivity(): ModalTimelineActivity
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesViewTagActivity(): ViewTagActivity
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesViewThreadActivity(): ViewThreadActivity
@ -93,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
@ -117,11 +110,14 @@ abstract class ActivitiesModule {
abstract fun contributesInstanceListActivity(): InstanceListActivity
@ContributesAndroidInjector
abstract fun contributesScheduledTootActivity(): ScheduledTootActivity
abstract fun contributesScheduledStatusActivity(): ScheduledStatusActivity
@ContributesAndroidInjector
abstract fun contributesAnnouncementsActivity(): AnnouncementsActivity
@ContributesAndroidInjector
abstract fun contributesDraftActivity(): DraftsActivity
@ContributesAndroidInjector
abstract fun contributesSplashActivity(): SplashActivity
}

View file

@ -61,7 +61,8 @@ class AppModule {
AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22,
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_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29,
AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31
)
.build()
}

View file

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

View file

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

View file

@ -45,37 +45,57 @@ data class Account(
localUsername
} else displayName
override fun hashCode(): Int {
return id.hashCode()
}
override fun equals(other: Any?): Boolean {
if (other !is Account) {
return false
}
return other.id == this.id
}
fun deepEquals(other: Account): Boolean {
return id == other.id &&
localUsername == other.localUsername &&
displayName == other.displayName &&
note == other.note &&
url == other.url &&
avatar == other.avatar &&
header == other.header &&
locked == other.locked &&
followersCount == other.followersCount &&
followingCount == other.followingCount &&
statusesCount == other.statusesCount &&
source == other.source &&
bot == other.bot &&
emojis == other.emojis &&
fields == other.fields &&
moved == other.moved
}
fun isRemote(): Boolean = this.username != this.localUsername
/**
* overriding equals & hashcode because Spanned does not always compare correctly otherwise
*/
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Account
if (id != other.id) return false
if (localUsername != other.localUsername) return false
if (username != other.username) return false
if (displayName != other.displayName) return false
if (note.toString() != other.note.toString()) return false
if (url != other.url) return false
if (avatar != other.avatar) return false
if (header != other.header) return false
if (locked != other.locked) return false
if (followersCount != other.followersCount) return false
if (followingCount != other.followingCount) return false
if (statusesCount != other.statusesCount) return false
if (source != other.source) return false
if (bot != other.bot) return false
if (emojis != other.emojis) return false
if (fields != other.fields) return false
if (moved != other.moved) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + localUsername.hashCode()
result = 31 * result + username.hashCode()
result = 31 * result + (displayName?.hashCode() ?: 0)
result = 31 * result + note.toString().hashCode()
result = 31 * result + url.hashCode()
result = 31 * result + avatar.hashCode()
result = 31 * result + header.hashCode()
result = 31 * result + locked.hashCode()
result = 31 * result + followersCount
result = 31 * result + followingCount
result = 31 * result + statusesCount
result = 31 * result + (source?.hashCode() ?: 0)
result = 31 * result + bot.hashCode()
result = 31 * result + (emojis?.hashCode() ?: 0)
result = 31 * result + (fields?.hashCode() ?: 0)
result = 31 * result + (moved?.hashCode() ?: 0)
return result
}
}
data class AccountSource(

View file

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

View file

@ -1,3 +1,3 @@
package com.keylesspalace.tusky.entity
data class HashTag(val name: String)
data class HashTag(val name: String, val url: String)

View file

@ -30,7 +30,8 @@ data class Instance(
@SerializedName("contact_account") val contactAccount: Account,
@SerializedName("max_toot_chars") val maxTootChars: Int?,
@SerializedName("max_bio_chars") val maxBioChars: Int?,
@SerializedName("poll_limits") val pollLimits: PollLimits?
@SerializedName("poll_limits") val pollConfiguration: PollConfiguration?,
val configuration: InstanceConfiguration?,
) {
override fun hashCode(): Int {
return uri.hashCode()
@ -45,7 +46,31 @@ data class Instance(
}
}
data class PollLimits(
data class PollConfiguration(
@SerializedName("max_options") val maxOptions: Int?,
@SerializedName("max_option_chars") val maxOptionChars: Int?
@SerializedName("max_option_chars") val maxOptionChars: Int?,
@SerializedName("max_characters_per_option") val maxCharactersPerOption: Int?,
@SerializedName("min_expiration") val minExpiration: Int?,
@SerializedName("max_expiration") val maxExpiration: Int?,
)
data class InstanceConfiguration(
val statuses: StatusConfiguration?,
@SerializedName("media_attachments") val mediaAttachments: MediaAttachmentConfiguration?,
val polls: PollConfiguration?,
)
data class StatusConfiguration(
@SerializedName("max_characters") val maxCharacters: Int?,
@SerializedName("max_media_attachments") val maxMediaAttachments: Int?,
@SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int?,
)
data class MediaAttachmentConfiguration(
@SerializedName("supported_mime_types") val supportedMimeTypes: List<String>?,
@SerializedName("image_size_limit") val imageSizeLimit: Int?,
@SerializedName("image_matrix_limit") val imageMatrixLimit: Int?,
@SerializedName("video_size_limit") val videoSizeLimit: Int?,
@SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int?,
@SerializedName("video_matrix_limit") val videoMatrixLimit: Int?,
)

View file

@ -0,0 +1,9 @@
package com.keylesspalace.tusky.entity
/**
* The same as Attachment, except the url is null - see https://docs.joinmastodon.org/methods/statuses/media/
* We are only interested in the id, so other attributes are omitted
*/
data class MediaUploadResult(
val id: String
)

View file

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

View file

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

View file

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

View file

@ -0,0 +1,39 @@
/* 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
) {
val name: String
get() = if (displayName.isNullOrEmpty()) {
localUsername
} else displayName
}

View file

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

View file

@ -41,11 +41,10 @@ import androidx.lifecycle.Lifecycle;
import com.keylesspalace.tusky.BaseActivity;
import com.keylesspalace.tusky.BottomSheetActivity;
import com.keylesspalace.tusky.MainActivity;
import com.keylesspalace.tusky.PostLookupFallbackBehavior;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.StatusListActivity;
import com.keylesspalace.tusky.ViewMediaActivity;
import com.keylesspalace.tusky.ViewTagActivity;
import com.keylesspalace.tusky.components.compose.ComposeActivity;
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions;
import com.keylesspalace.tusky.components.report.ReportActivity;
@ -162,8 +161,6 @@ public abstract class SFragment extends Fragment implements Injectable {
final String accountId = status.getActionableStatus().getAccount().getId();
final String accountUsername = status.getActionableStatus().getAccount().getUsername();
final String statusUrl = status.getActionableStatus().getUrl();
List<AccountEntity> accounts = accountManager.getAllAccountsOrderedByActive();
String openAsTitle = null;
String loggedInAccountId = null;
AccountEntity activeAccount = accountManager.getActiveAccount();
@ -201,24 +198,12 @@ public abstract class SFragment extends Fragment implements Injectable {
Menu menu = popup.getMenu();
MenuItem openAsItem = menu.findItem(R.id.status_open_as);
switch (accounts.size()) {
case 0:
case 1:
openAsItem.setVisible(false);
break;
case 2:
for (AccountEntity account : accounts) {
if (account != activeAccount) {
openAsTitle = String.format(getString(R.string.action_open_as), account.getFullName());
break;
}
}
break;
default:
openAsTitle = String.format(getString(R.string.action_open_as), "");
break;
String openAsText = ((BaseActivity)getActivity()).getOpenAsText();
if (openAsText == null) {
openAsItem.setVisible(false);
} else {
openAsItem.setTitle(openAsText);
}
openAsItem.setTitle(openAsTitle);
MenuItem muteConversationItem = menu.findItem(R.id.status_mute_conversation);
boolean mutable = statusIsByCurrentUser || accountIsInMentions(activeAccount, status.getMentions());
@ -231,7 +216,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();
@ -245,15 +230,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: {
@ -378,15 +363,14 @@ public abstract class SFragment extends Fragment implements Injectable {
}
default:
case UNKNOWN: {
LinkHelper.openLink(active.getAttachment().getUrl(), getContext());
LinkHelper.openLink(requireContext(), active.getAttachment().getUrl());
break;
}
}
}
protected void viewTag(String tag) {
Intent intent = new Intent(getContext(), ViewTagActivity.class);
intent.putExtra("hashtag", tag);
Intent intent = StatusListActivity.newHashtagIntent(requireContext(), tag);
startActivity(intent);
}
@ -396,7 +380,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())
@ -419,7 +403,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())
@ -431,7 +415,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());
@ -456,18 +440,9 @@ public abstract class SFragment extends Fragment implements Injectable {
.show();
}
private void openAsAccount(String statusUrl, AccountEntity account) {
accountManager.setActiveAccount(account);
Intent intent = new Intent(getContext(), MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
intent.putExtra(MainActivity.STATUS_URL, statusUrl);
startActivity(intent);
((BaseActivity) getActivity()).finishWithoutSlideOutAnimation();
}
private void showOpenAsDialog(String statusUrl, CharSequence dialogTitle) {
BaseActivity activity = (BaseActivity) getActivity();
activity.showAccountChooserDialog(dialogTitle, false, account -> openAsAccount(statusUrl, account));
activity.showAccountChooserDialog(dialogTitle, false, account -> activity.openAsAccount(statusUrl, account));
}
private void downloadAllMedia(Status status) {

View file

@ -330,7 +330,7 @@ public final class ViewThreadFragment extends SFragment implements
// already viewing the status with this url
// probably just a preview federated and the user is clicking again to view more -> open the browser
// this can happen with some friendica statuses
LinkHelper.openLink(url, requireContext());
LinkHelper.openLink(requireContext(), url);
return;
}
super.onViewUrl(url);

View file

@ -13,10 +13,10 @@
* 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.interfaces;
package com.keylesspalace.tusky.interfaces
public interface LinkListener {
void onViewTag(String tag);
void onViewAccount(String id);
void onViewUrl(String url);
interface LinkListener {
fun onViewTag(tag: String)
fun onViewAccount(id: String)
fun onViewUrl(url: String)
}

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