Merge remote-tracking branch 'tuskyapp/develop'

# Conflicts:
#	app/build.gradle
#	app/src/main/java/com/keylesspalace/tusky/MainActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/entity/Account.kt
#	app/src/main/res/values-zh-rCN/strings.xml
#	app/src/main/res/values-zh-rTW/strings.xml
#	app/src/main/res/values/strings.xml
This commit is contained in:
kyori19 2022-05-19 01:38:49 +09:00
commit 25bee533f1
No known key found for this signature in database
GPG Key ID: CB37D0651E7F52AA
88 changed files with 1960 additions and 550 deletions

View File

@ -7,9 +7,13 @@ apply from: "../instance-build.gradle"
def getGitSha = {
def stdout = new ByteArrayOutputStream()
exec {
commandLine 'git', 'rev-parse', '--short', 'HEAD'
standardOutput = stdout
try {
exec {
commandLine 'git', 'rev-parse', '--short', 'HEAD'
standardOutput = stdout
}
} catch (Exception e) {
return "unknown"
}
return stdout.toString().trim()
}
@ -101,7 +105,7 @@ ext.roomVersion = '2.4.2'
ext.retrofitVersion = '2.9.0'
ext.okhttpVersion = '4.9.3'
ext.glideVersion = '4.13.1'
ext.daggerVersion = '2.41'
ext.daggerVersion = '2.42'
ext.materialdrawerVersion = '8.4.5'
ext.emoji2_version = '1.1.0'
ext.filemojicompat_version = '3.2.1'
@ -143,7 +147,7 @@ dependencies {
kapt "androidx.room:room-compiler:$roomVersion"
implementation 'androidx.core:core-splashscreen:1.0.0-beta02'
implementation "com.google.android.material:material:1.5.0"
implementation "com.google.android.material:material:1.6.0"
implementation "com.google.code.gson:gson:2.9.0"
@ -190,6 +194,9 @@ dependencies {
implementation "de.c1710:filemojicompat:$filemojicompat_version"
implementation "de.c1710:filemojicompat-defaults:$filemojicompat_version"
implementation "org.bouncycastle:bcprov-jdk15on:1.70"
implementation "com.github.UnifiedPush:android-connector:2.0.0"
testImplementation "androidx.test.ext:junit:1.1.3"
testImplementation "org.robolectric:robolectric:4.4"
testImplementation "org.mockito:mockito-inline:4.4.0"

View File

@ -0,0 +1,863 @@
{
"formatVersion": 1,
"database": {
"version": 36,
"identityHash": "92ef93a129d5370539d6a62fd16f53d8",
"entities": [
{
"tableName": "DraftEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentWarning",
"columnName": "contentWarning",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "failedToSend",
"columnName": "failedToSend",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "AccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "domain",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profilePictureUrl",
"columnName": "profilePictureUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsMentioned",
"columnName": "notificationsMentioned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowed",
"columnName": "notificationsFollowed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowRequested",
"columnName": "notificationsFollowRequested",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsReblogged",
"columnName": "notificationsReblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFavorited",
"columnName": "notificationsFavorited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsPolls",
"columnName": "notificationsPolls",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsSubscriptions",
"columnName": "notificationsSubscriptions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsSignUps",
"columnName": "notificationsSignUps",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsUpdates",
"columnName": "notificationsUpdates",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationSound",
"columnName": "notificationSound",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationVibration",
"columnName": "notificationVibration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationLight",
"columnName": "notificationLight",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultPostPrivacy",
"columnName": "defaultPostPrivacy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultMediaSensitivity",
"columnName": "defaultMediaSensitivity",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysShowSensitiveMedia",
"columnName": "alwaysShowSensitiveMedia",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysOpenSpoiler",
"columnName": "alwaysOpenSpoiler",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mediaPreviewEnabled",
"columnName": "mediaPreviewEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastNotificationId",
"columnName": "lastNotificationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activeNotifications",
"columnName": "activeNotifications",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tabPreferences",
"columnName": "tabPreferences",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsFilter",
"columnName": "notificationsFilter",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "oauthScopes",
"columnName": "oauthScopes",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unifiedPushUrl",
"columnName": "unifiedPushUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pushPubKey",
"columnName": "pushPubKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pushPrivKey",
"columnName": "pushPrivKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pushAuth",
"columnName": "pushAuth",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pushServerKey",
"columnName": "pushServerKey",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_AccountEntity_domain_accountId",
"unique": true,
"columnNames": [
"domain",
"accountId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)"
}
],
"foreignKeys": []
},
{
"tableName": "InstanceEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))",
"fields": [
{
"fieldPath": "instance",
"columnName": "instance",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojiList",
"columnName": "emojiList",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "maximumTootCharacters",
"columnName": "maximumTootCharacters",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptions",
"columnName": "maxPollOptions",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptionLength",
"columnName": "maxPollOptionLength",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "minPollDuration",
"columnName": "minPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollDuration",
"columnName": "maxPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "charactersReservedPerUrl",
"columnName": "charactersReservedPerUrl",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"instance"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TimelineStatusEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `quote` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authorServerId",
"columnName": "authorServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToAccountId",
"columnName": "inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogsCount",
"columnName": "reblogsCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favouritesCount",
"columnName": "favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "spoilerText",
"columnName": "spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogServerId",
"columnName": "reblogServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogAccountId",
"columnName": "reblogAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "expanded",
"columnName": "expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentCollapsed",
"columnName": "contentCollapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentShowing",
"columnName": "contentShowing",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "card",
"columnName": "card",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "quote",
"columnName": "quote",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
"unique": false,
"columnNames": [
"authorServerId",
"timelineUserId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
}
],
"foreignKeys": [
{
"table": "TimelineAccountEntity",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"authorServerId",
"timelineUserId"
],
"referencedColumns": [
"serverId",
"timelineUserId"
]
}
]
},
{
"tableName": "TimelineAccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localUsername",
"columnName": "localUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bot",
"columnName": "bot",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ConversationEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `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_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.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, '92ef93a129d5370539d6a62fd16f53d8')"
]
}
}

View File

@ -15,6 +15,7 @@
<application
android:name=".TuskyApplication"
android:appCategory="social"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@ -147,6 +148,29 @@
android:name=".receiver.SendStatusBroadcastReceiver"
android:enabled="true"
android:exported="false" />
<receiver
android:exported="true"
android:enabled="true"
android:name=".receiver.UnifiedPushBroadcastReceiver"
tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
<action android:name="org.unifiedpush.android.connector.REGISTRATION_REFUSED"/>
</intent-filter>
</receiver>
<receiver
android:exported="true"
android:enabled="true"
android:name=".receiver.NotificationBlockStateBroadcastReceiver"
tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="android.app.action.NOTIFICATION_CHANNEL_BLOCK_STATE_CHANGED"/>
<action android:name="android.app.action.NOTIFICATION_CHANNEL_GROUP_BLOCK_STATE_CHANGED"/>
</intent-filter>
</receiver>
<service
android:name=".service.TuskyTileService"

View File

@ -198,9 +198,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
).show()
}
private fun onListSelected(listId: String) {
private fun onListSelected(listId: String, listTitle: String) {
startActivityWithSlideInAnimation(
StatusListActivity.newListIntent(this, listId)
StatusListActivity.newListIntent(this, listId, listTitle)
)
}
@ -270,7 +270,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
override fun onClick(v: View) {
if (v == itemView) {
onListSelected(getItem(bindingAdapterPosition).id)
val list = getItem(bindingAdapterPosition)
onListSelected(list.id, list.title)
} else {
onMore(getItem(bindingAdapterPosition), v)
}

View File

@ -75,6 +75,10 @@ import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.drafts.DraftsActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.notifications.disableAllNotifications
import com.keylesspalace.tusky.components.notifications.disableUnifiedPushNotificationsForAccount
import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback
import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary
import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
import com.keylesspalace.tusky.components.search.SearchActivity
@ -267,23 +271,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
setupTabs(showNotificationTab)
val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin)
binding.viewPager.setPageTransformer(MarginPageTransformer(pageMargin))
val uswSwipeForTabs = PreferenceManager.getDefaultSharedPreferences(this)
.getBoolean("enableSwipeForTabs", true)
binding.viewPager.isUserInputEnabled = uswSwipeForTabs
if (PreferenceManager.getDefaultSharedPreferences(this).getBoolean(PrefKeys.VIEW_PAGER_OFF_SCREEN_LIMIT, false)) {
binding.viewPager.offscreenPageLimit = 9
}
// Setup push notifications
if (NotificationHelper.areNotificationsEnabled(this, accountManager)) {
NotificationHelper.enablePullNotifications(this)
} else {
NotificationHelper.disablePullNotifications(this)
}
eventHub.events
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
@ -796,6 +787,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
(adapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect()
}
updateProfiles()
keepScreenOn()
return popups
}
@ -811,7 +804,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
// open LoginActivity to add new account
if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) {
startActivityWithSlideInAnimation(LoginActivity.getIntent(this, true))
startActivityWithSlideInAnimation(LoginActivity.getIntent(this, LoginActivity.MODE_ADDITIONAL_LOGIN))
return false
}
// change Account
@ -842,6 +835,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
.setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName))
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
lifecycleScope.launch {
// Only disable UnifiedPush for this account -- do not call disableNotifications(),
// which unnecessarily disables it for all accounts and then re-enables it again at
// the next launch
disableUnifiedPushNotificationsForAccount(this@MainActivity, activeAccount)
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this@MainActivity)
cacheUpdater.clearForUser(activeAccount.id)
conversationRepository.deleteCacheForAccount(activeAccount.id)
@ -856,7 +853,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
NotificationHelper.disablePullNotifications(this@MainActivity)
}
val intent = if (newAccount == null) {
LoginActivity.getIntent(this@MainActivity, false)
LoginActivity.getIntent(this@MainActivity, LoginActivity.MODE_DEFAULT)
} else {
Intent(this@MainActivity, MainActivity::class.java)
}
@ -890,6 +887,16 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
accountManager.updateActiveAccount(me)
NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this)
// Setup push notifications
showMigrationNoticeIfNecessary(this, binding.root, accountManager)
if (NotificationHelper.areNotificationsEnabled(this, accountManager)) {
lifecycleScope.launch {
enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager)
}
} else {
disableAllNotifications(this, accountManager)
}
accountLocked = me.locked
updateProfiles()

View File

@ -46,7 +46,7 @@ class SplashActivity : AppCompatActivity(), Injectable {
val intent = if (accountManager.activeAccount != null) {
Intent(this, MainActivity::class.java)
} else {
LoginActivity.getIntent(this, false)
LoginActivity.getIntent(this, LoginActivity.MODE_DEFAULT)
}
startActivity(intent)
finish()

View File

@ -59,7 +59,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
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)
else -> intent.getStringExtra(EXTRA_LIST_TITLE)
}
supportActionBar?.run {
@ -94,6 +94,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
private const val EXTRA_KIND = "kind"
private const val EXTRA_LIST_ID = "id"
private const val EXTRA_LIST_TITLE = "title"
private const val EXTRA_HASHTAG = "tag"
fun newFavouritesIntent(context: Context) =
@ -106,10 +107,11 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
putExtra(EXTRA_KIND, Kind.BOOKMARKS.name)
}
fun newListIntent(context: Context, listId: String) =
fun newListIntent(context: Context, listId: String, listTitle: String) =
Intent(context, StatusListActivity::class.java).apply {
putExtra(EXTRA_KIND, Kind.LIST.name)
putExtra(EXTRA_LIST_ID, listId)
putExtra(EXTRA_LIST_TITLE, listTitle)
}
@JvmStatic

View File

@ -539,8 +539,13 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null);
String wholeMessage = String.format(format, displayName);
final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage);
str.setSpan(new StyleSpan(Typeface.BOLD), 0, displayName.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
int displayNameIndex = format.indexOf("%s");
str.setSpan(
new StyleSpan(Typeface.BOLD),
displayNameIndex,
displayNameIndex + displayName.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
);
CharSequence emojifiedText = CustomEmojiHelper.emojify(
str, notificationViewData.getAccount().getEmojis(), message, statusDisplayOptions.animateEmojis()
);

View File

@ -84,6 +84,9 @@ import com.keylesspalace.tusky.view.showMuteAccountDialog
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import java.text.NumberFormat
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
import javax.inject.Inject
import kotlin.math.abs
@ -418,6 +421,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
updateToolbar()
updateMovedAccount()
updateRemoteAccount()
updateAccountJoinedDate()
updateAccountStats()
invalidateOptionsMenu()
@ -427,6 +431,20 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
}
}
private fun updateAccountJoinedDate() {
loadedAccount?.let { account ->
try {
binding.accountDateJoined.text = resources.getString(
R.string.account_date_joined,
SimpleDateFormat("MMMM, yyyy", Locale.getDefault()).format(account.createdAt)
)
binding.accountDateJoined.visibility = View.VISIBLE
} catch (e: ParseException) {
binding.accountDateJoined.visibility = View.GONE
}
}
}
/**
* Load account's avatar and header image
*/

View File

@ -82,7 +82,6 @@ import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.ComposeTokenizer
import com.keylesspalace.tusky.util.PickMediaFiles
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.afterTextChanged
@ -361,7 +360,8 @@ class ComposeActivity :
ComposeAutoCompleteAdapter(
this,
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
)
)
binding.composeEditField.setTokenizer(ComposeTokenizer())

View File

@ -1,320 +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.components.compose;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.Filter;
import android.widget.Filterable;
import android.widget.ImageView;
import android.widget.TextView;
import com.bumptech.glide.Glide;
import com.keylesspalace.tusky.R;
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;
import java.util.ArrayList;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* Created by charlag on 12/11/17.
*/
public class ComposeAutoCompleteAdapter extends BaseAdapter
implements Filterable {
private static final int ACCOUNT_VIEW_TYPE = 1;
private static final int HASHTAG_VIEW_TYPE = 2;
private static final int EMOJI_VIEW_TYPE = 3;
private static final int SEPARATOR_VIEW_TYPE = 0;
private final ArrayList<AutocompleteResult> resultList;
private final AutocompletionProvider autocompletionProvider;
private final boolean animateAvatar;
private final boolean animateEmojis;
public ComposeAutoCompleteAdapter(AutocompletionProvider autocompletionProvider, boolean animateAvatar, boolean animateEmojis) {
super();
resultList = new ArrayList<>();
this.autocompletionProvider = autocompletionProvider;
this.animateAvatar = animateAvatar;
this.animateEmojis = animateEmojis;
}
@Override
public int getCount() {
return resultList.size();
}
@Override
public AutocompleteResult getItem(int index) {
return resultList.get(index);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
@NonNull
public Filter getFilter() {
return new Filter() {
@Override
public CharSequence convertResultToString(Object resultValue) {
if (resultValue instanceof AccountResult) {
return formatUsername(((AccountResult) resultValue));
} else if (resultValue instanceof HashtagResult) {
return formatHashtag((HashtagResult) resultValue);
} else if (resultValue instanceof EmojiResult) {
return formatEmoji((EmojiResult) resultValue);
} else {
return "";
}
}
// This method is invoked in a worker thread.
@Override
protected FilterResults performFiltering(CharSequence constraint) {
FilterResults filterResults = new FilterResults();
if (constraint != null) {
List<AutocompleteResult> results =
autocompletionProvider.search(constraint.toString());
filterResults.values = results;
filterResults.count = results.size();
}
return filterResults;
}
@SuppressWarnings("unchecked")
@Override
protected void publishResults(CharSequence constraint, FilterResults results) {
if (results != null && results.count > 0) {
resultList.clear();
resultList.addAll((List<AutocompleteResult>) results.values);
notifyDataSetChanged();
} else {
notifyDataSetInvalidated();
}
}
};
}
@Override
@NonNull
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
View view = convertView;
final Context context = parent.getContext();
switch (getItemViewType(position)) {
case ACCOUNT_VIEW_TYPE:
AccountViewHolder accountViewHolder;
if (convertView == null) {
view = ((LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
.inflate(R.layout.item_autocomplete_account, parent, false);
}
if (view.getTag() == null) {
view.setTag(new AccountViewHolder(view));
}
accountViewHolder = (AccountViewHolder) view.getTag();
AccountResult accountResult = ((AccountResult) getItem(position));
if (accountResult != null) {
TimelineAccount account = accountResult.account;
String formattedUsername = context.getString(
R.string.post_username_format,
account.getUsername()
);
accountViewHolder.username.setText(formattedUsername);
CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(),
account.getEmojis(), accountViewHolder.displayName, animateEmojis);
accountViewHolder.displayName.setText(emojifiedName);
int avatarRadius = accountViewHolder.avatar.getContext().getResources()
.getDimensionPixelSize(R.dimen.avatar_radius_42dp);
ImageLoadingHelper.loadAvatar(
account.getAvatar(),
accountViewHolder.avatar,
avatarRadius,
animateAvatar
);
}
break;
case HASHTAG_VIEW_TYPE:
if (convertView == null) {
view = ((LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
.inflate(R.layout.item_autocomplete_hashtag, parent, false);
}
HashtagResult result = (HashtagResult) getItem(position);
if (result != null) {
((TextView) view).setText(formatHashtag(result));
}
break;
case EMOJI_VIEW_TYPE:
EmojiViewHolder emojiViewHolder;
if (convertView == null) {
view = ((LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
.inflate(R.layout.item_autocomplete_emoji, parent, false);
}
if (view.getTag() == null) {
view.setTag(new EmojiViewHolder(view));
}
emojiViewHolder = (EmojiViewHolder) view.getTag();
EmojiResult emojiResult = ((EmojiResult) getItem(position));
if (emojiResult != null) {
Emoji emoji = emojiResult.emoji;
String formattedShortcode = context.getString(
R.string.emoji_shortcode_format,
emoji.getShortcode()
);
emojiViewHolder.shortcode.setText(formattedShortcode);
Glide.with(emojiViewHolder.preview)
.load(emoji.getUrl())
.into(emojiViewHolder.preview);
}
break;
case SEPARATOR_VIEW_TYPE:
if (convertView == null) {
view = ((LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
.inflate(R.layout.item_autocomplete_divider, parent, false);
}
break;
default:
throw new AssertionError("unknown view type");
}
return view;
}
private static String formatUsername(AccountResult result) {
return String.format("@%s", result.account.getUsername());
}
private static String formatHashtag(HashtagResult result) {
return String.format("#%s", result.hashtag);
}
private static String formatEmoji(EmojiResult result) {
return String.format(":%s:", result.emoji.getShortcode());
}
@Override
public int getViewTypeCount() {
return 4;
}
@Override
public int getItemViewType(int position) {
AutocompleteResult item = getItem(position);
if (item instanceof AccountResult) {
return ACCOUNT_VIEW_TYPE;
} else if (item instanceof HashtagResult) {
return HASHTAG_VIEW_TYPE;
} else if (item instanceof EmojiResult) {
return EMOJI_VIEW_TYPE;
} else {
return SEPARATOR_VIEW_TYPE;
}
}
@Override
public boolean areAllItemsEnabled() {
// there may be separators
return false;
}
@Override
public boolean isEnabled(int position) {
return !(getItem(position) instanceof ResultSeparator);
}
public abstract static class AutocompleteResult {
AutocompleteResult() {
}
}
public final static class AccountResult extends AutocompleteResult {
private final TimelineAccount account;
public AccountResult(TimelineAccount account) {
this.account = account;
}
}
public final static class HashtagResult extends AutocompleteResult {
private final String hashtag;
public HashtagResult(HashTag hashtag) {
this.hashtag = hashtag.getName();
}
}
public final static class EmojiResult extends AutocompleteResult {
private final Emoji emoji;
public EmojiResult(Emoji emoji) {
this.emoji = emoji;
}
}
public final static class ResultSeparator extends AutocompleteResult {}
public interface AutocompletionProvider {
List<AutocompleteResult> search(String mention);
}
private class AccountViewHolder {
final TextView username;
final TextView displayName;
final ImageView avatar;
private AccountViewHolder(View view) {
username = view.findViewById(R.id.username);
displayName = view.findViewById(R.id.display_name);
avatar = view.findViewById(R.id.avatar);
}
}
private class EmojiViewHolder {
final TextView shortcode;
final ImageView preview;
private EmojiViewHolder(View view) {
shortcode = view.findViewById(R.id.shortcode);
preview = view.findViewById(R.id.preview);
}
}
}

View File

@ -0,0 +1,175 @@
/* Copyright 2022 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.compose
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.Filter
import android.widget.Filterable
import androidx.annotation.WorkerThread
import com.bumptech.glide.Glide
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemAutocompleteAccountBinding
import com.keylesspalace.tusky.databinding.ItemAutocompleteEmojiBinding
import com.keylesspalace.tusky.databinding.ItemAutocompleteHashtagBinding
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.visible
class ComposeAutoCompleteAdapter(
private val autocompletionProvider: AutocompletionProvider,
private val animateAvatar: Boolean,
private val animateEmojis: Boolean,
private val showBotBadge: Boolean
) : BaseAdapter(), Filterable {
private var resultList: List<AutocompleteResult> = emptyList()
override fun getCount() = resultList.size
override fun getItem(index: Int): AutocompleteResult {
return resultList[index]
}
override fun getItemId(position: Int): Long {
return position.toLong()
}
override fun getFilter(): Filter {
return object : Filter() {
override fun convertResultToString(resultValue: Any): CharSequence {
return when (resultValue) {
is AutocompleteResult.AccountResult -> formatUsername(resultValue)
is AutocompleteResult.HashtagResult -> formatHashtag(resultValue)
is AutocompleteResult.EmojiResult -> formatEmoji(resultValue)
else -> ""
}
}
@WorkerThread
override fun performFiltering(constraint: CharSequence?): FilterResults {
val filterResults = FilterResults()
if (constraint != null) {
val results = autocompletionProvider.search(constraint.toString())
filterResults.values = results
filterResults.count = results.size
}
return filterResults
}
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
if (results.count > 0) {
resultList = results.values as List<AutocompleteResult>
notifyDataSetChanged()
} else {
notifyDataSetInvalidated()
}
}
}
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val itemViewType = getItemViewType(position)
val context = parent.context
val view: View = convertView ?: run {
val layoutInflater = LayoutInflater.from(context)
val binding = when (itemViewType) {
ACCOUNT_VIEW_TYPE -> ItemAutocompleteAccountBinding.inflate(layoutInflater)
HASHTAG_VIEW_TYPE -> ItemAutocompleteHashtagBinding.inflate(layoutInflater)
EMOJI_VIEW_TYPE -> ItemAutocompleteEmojiBinding.inflate(layoutInflater)
else -> throw AssertionError("unknown view type")
}
binding.root.tag = binding
binding.root
}
when (val binding = view.tag) {
is ItemAutocompleteAccountBinding -> {
val accountResult = getItem(position) as AutocompleteResult.AccountResult
val account = accountResult.account
binding.username.text = context.getString(R.string.post_username_format, account.username)
binding.displayName.text = account.name.emojify(account.emojis, binding.displayName, animateEmojis)
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp)
loadAvatar(
account.avatar,
binding.avatar,
avatarRadius,
animateAvatar
)
binding.avatarBadge.visible(showBotBadge && account.bot)
}
is ItemAutocompleteHashtagBinding -> {
val result = getItem(position) as AutocompleteResult.HashtagResult
binding.root.text = formatHashtag(result)
}
is ItemAutocompleteEmojiBinding -> {
val emojiResult = getItem(position) as AutocompleteResult.EmojiResult
val (shortcode, url) = emojiResult.emoji
binding.shortcode.text = context.getString(R.string.emoji_shortcode_format, shortcode)
Glide.with(binding.preview)
.load(url)
.into(binding.preview)
}
}
return view
}
override fun getViewTypeCount() = 3
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is AutocompleteResult.AccountResult -> ACCOUNT_VIEW_TYPE
is AutocompleteResult.HashtagResult -> HASHTAG_VIEW_TYPE
is AutocompleteResult.EmojiResult -> EMOJI_VIEW_TYPE
}
}
sealed class AutocompleteResult {
class AccountResult(val account: TimelineAccount) : AutocompleteResult()
class HashtagResult(val hashtag: String) : AutocompleteResult()
class EmojiResult(val emoji: Emoji) : AutocompleteResult()
}
interface AutocompletionProvider {
fun search(token: String): List<AutocompleteResult>
}
companion object {
private const val ACCOUNT_VIEW_TYPE = 0
private const val HASHTAG_VIEW_TYPE = 1
private const val EMOJI_VIEW_TYPE = 2
private fun formatUsername(result: AutocompleteResult.AccountResult): String {
return String.format("@%s", result.account.username)
}
private fun formatHashtag(result: AutocompleteResult.HashtagResult): String {
return String.format("#%s", result.hashtag)
}
private fun formatEmoji(result: AutocompleteResult.EmojiResult): String {
return String.format(":%s:", result.emoji.shortcode)
}
}
}

View File

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.util
package com.keylesspalace.tusky.components.compose
import android.text.SpannableString
import android.text.Spanned

View File

@ -24,6 +24,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
@ -38,6 +39,7 @@ import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.combineLiveData
import com.keylesspalace.tusky.util.randomAlphanumericString
import com.keylesspalace.tusky.util.result
import com.keylesspalace.tusky.util.toLiveData
import io.reactivex.rxjava3.core.Observable
import kotlinx.coroutines.Dispatchers
@ -51,7 +53,6 @@ import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.rxSingle
import kotlinx.coroutines.withContext
import java.util.Locale
import javax.inject.Inject
class ComposeViewModel @Inject constructor(
@ -195,7 +196,7 @@ class ComposeViewModel @Inject constructor(
fun removeMediaFromQueue(item: QueuedMedia) {
mediaToJob[item.localId]?.cancel()
media.update { mediaValue -> mediaValue.filter { it.localId == item.localId } }
media.update { mediaValue -> mediaValue.filter { it.localId != item.localId } }
}
fun toggleMarkSensitive() {
@ -342,48 +343,39 @@ class ComposeViewModel @Inject constructor(
return true
}
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
when (token[0]) {
'@' -> {
return try {
api.searchAccounts(query = token.substring(1), limit = 10)
.blockingGet()
.map { ComposeAutoCompleteAdapter.AccountResult(it) }
} catch (e: Throwable) {
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
emptyList()
}
return api.searchAccountsCall(query = token.substring(1), limit = 10)
.result()
.fold({ accounts ->
accounts.map { AutocompleteResult.AccountResult(it) }
}, { e ->
Log.e(TAG, "Autocomplete search for $token failed.", e)
emptyList()
})
}
'#' -> {
return try {
api.searchObservable(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
.blockingGet()
.hashtags
.map { ComposeAutoCompleteAdapter.HashtagResult(it) }
} catch (e: Throwable) {
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
emptyList()
}
return api.searchCall(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
.result()
.fold({ searchResult ->
searchResult.hashtags.map { AutocompleteResult.HashtagResult(it.name) }
}, { e ->
Log.e(TAG, "Autocomplete search for $token failed.", e)
emptyList()
})
}
':' -> {
val emojiList = emoji.value ?: return emptyList()
val incomplete = token.substring(1)
val incomplete = token.substring(1).lowercase(Locale.ROOT)
val results = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
val resultsInside = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
for (emoji in emojiList) {
val shortcode = emoji.shortcode.lowercase(Locale.ROOT)
if (shortcode.startsWith(incomplete)) {
results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
} else if (shortcode.indexOf(incomplete, 1) != -1) {
resultsInside.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
}
return emojiList.filter { emoji ->
emoji.shortcode.contains(incomplete, ignoreCase = true)
}.sortedBy { emoji ->
emoji.shortcode.indexOf(incomplete, ignoreCase = true)
}.map { emoji ->
AutocompleteResult.EmojiResult(emoji)
}
if (results.isNotEmpty() && resultsInside.isNotEmpty()) {
results.add(ComposeAutoCompleteAdapter.ResultSeparator())
}
results.addAll(resultsInside)
return results
}
else -> {
Log.w(TAG, "Unexpected autocompletion token: $token")

View File

@ -90,12 +90,17 @@ class LoginActivity : BaseActivity(), Injectable {
if (savedInstanceState == null &&
BuildConfig.CUSTOM_INSTANCE.isNotBlank() &&
!isAdditionalLogin()
!isAdditionalLogin() && !isAccountMigration()
) {
binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE)
binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length)
}
if (isAccountMigration()) {
binding.domainEditText.setText(accountManager.activeAccount!!.domain)
binding.domainEditText.isEnabled = false
}
if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) {
Glide.with(binding.loginLogo)
.load(BuildConfig.CUSTOM_LOGO_URL)
@ -118,7 +123,7 @@ class LoginActivity : BaseActivity(), Injectable {
textView?.movementMethod = LinkMovementMethod.getInstance()
}
if (isAdditionalLogin()) {
if (isAdditionalLogin() || isAccountMigration()) {
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowTitleEnabled(false)
@ -133,7 +138,7 @@ class LoginActivity : BaseActivity(), Injectable {
override fun finish() {
super.finish()
if (isAdditionalLogin()) {
if (isAdditionalLogin() || isAccountMigration()) {
overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right)
}
}
@ -223,7 +228,7 @@ class LoginActivity : BaseActivity(), Injectable {
domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code"
).fold(
{ accessToken ->
accountManager.addAccount(accessToken.accessToken, domain)
accountManager.addAccount(accessToken.accessToken, domain, OAUTH_SCOPES)
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
@ -255,19 +260,28 @@ class LoginActivity : BaseActivity(), Injectable {
}
private fun isAdditionalLogin(): Boolean {
return intent.getBooleanExtra(LOGIN_MODE, false)
return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_ADDITIONAL_LOGIN
}
private fun isAccountMigration(): Boolean {
return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_MIGRATION
}
companion object {
private const val TAG = "LoginActivity" // logging tag
private const val OAUTH_SCOPES = "read write follow"
private const val OAUTH_SCOPES = "read write follow push"
private const val LOGIN_MODE = "LOGIN_MODE"
private const val DOMAIN = "domain"
private const val CLIENT_ID = "clientId"
private const val CLIENT_SECRET = "clientSecret"
const val MODE_DEFAULT = 0
const val MODE_ADDITIONAL_LOGIN = 1
// "Migration" is used to update the OAuth scope granted to the client
const val MODE_MIGRATION = 2
@JvmStatic
fun getIntent(context: Context, mode: Boolean): Intent {
fun getIntent(context: Context, mode: Int): Intent {
val loginIntent = Intent(context, LoginActivity::class.java)
loginIntent.putExtra(LOGIN_MODE, mode)
return loginIntent

View File

@ -57,6 +57,7 @@ import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.PollOption;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver;
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver;
import com.keylesspalace.tusky.util.StringUtils;
@ -539,13 +540,18 @@ public class NotificationHelper {
}
}
private static boolean filterNotification(AccountEntity account, Notification notification,
public static boolean filterNotification(AccountEntity account, Notification notification,
Context context) {
return filterNotification(account, notification.getType(), context);
}
public static boolean filterNotification(AccountEntity account, Notification.Type type,
Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
String channelId = getChannelId(account, notification);
String channelId = getChannelId(account, type);
if(channelId == null) {
// unknown notificationtype
return false;
@ -554,7 +560,7 @@ public class NotificationHelper {
return channel.getImportance() > NotificationManager.IMPORTANCE_NONE;
}
switch (notification.getType()) {
switch (type) {
case MENTION:
return account.getNotificationsMentioned();
case STATUS:
@ -580,7 +586,12 @@ public class NotificationHelper {
@Nullable
private static String getChannelId(AccountEntity account, Notification notification) {
switch (notification.getType()) {
return getChannelId(account, notification.getType());
}
@Nullable
private static String getChannelId(AccountEntity account, Notification.Type type) {
switch (type) {
case MENTION:
return CHANNEL_MENTION + account.getIdentifier();
case STATUS:

View File

@ -0,0 +1,220 @@
/* Copyright 2022 Tusky contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
@file:JvmName("PushNotificationHelper")
package com.keylesspalace.tusky.components.notifications
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.util.Log
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.preference.PreferenceManager
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.CryptoUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.unifiedpush.android.connector.UnifiedPush
import retrofit2.HttpException
private const val TAG = "PushNotificationHelper"
private const val KEY_MIGRATION_NOTICE_DISMISSED = "migration_notice_dismissed"
private fun anyAccountNeedsMigration(accountManager: AccountManager): Boolean =
accountManager.accounts.any(::accountNeedsMigration)
private fun accountNeedsMigration(account: AccountEntity): Boolean =
!account.oauthScopes.contains("push")
fun currentAccountNeedsMigration(accountManager: AccountManager): Boolean =
accountManager.activeAccount?.let(::accountNeedsMigration) ?: false
fun showMigrationNoticeIfNecessary(context: Context, parent: View, accountManager: AccountManager) {
// No point showing anything if we cannot enable it
if (!isUnifiedPushAvailable(context)) return
if (!anyAccountNeedsMigration(accountManager)) return
val pm = PreferenceManager.getDefaultSharedPreferences(context)
if (pm.getBoolean(KEY_MIGRATION_NOTICE_DISMISSED, false)) return
Snackbar.make(parent, R.string.tips_push_notification_migration, Snackbar.LENGTH_INDEFINITE).apply {
setAction(R.string.action_details) { showMigrationExplanationDialog(context, accountManager) }
show()
}
}
private fun showMigrationExplanationDialog(context: Context, accountManager: AccountManager) {
AlertDialog.Builder(context).apply {
if (currentAccountNeedsMigration(accountManager)) {
setMessage(R.string.dialog_push_notification_migration)
setPositiveButton(R.string.title_migration_relogin) { _, _ ->
context.startActivity(LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION))
}
} else {
setMessage(R.string.dialog_push_notification_migration_other_accounts)
}
setNegativeButton(R.string.action_dismiss) { dialog, _ ->
val pm = PreferenceManager.getDefaultSharedPreferences(context)
pm.edit().putBoolean(KEY_MIGRATION_NOTICE_DISMISSED, true).apply()
dialog.dismiss()
}
show()
}
}
private suspend fun enableUnifiedPushNotificationsForAccount(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity) {
if (isUnifiedPushNotificationEnabledForAccount(account)) {
// Already registered, update the subscription to match notification settings
updateUnifiedPushSubscription(context, api, accountManager, account)
} else {
UnifiedPush.registerAppWithDialog(context, account.id.toString())
}
}
fun disableUnifiedPushNotificationsForAccount(context: Context, account: AccountEntity) {
if (!isUnifiedPushNotificationEnabledForAccount(account)) {
// Not registered
return
}
UnifiedPush.unregisterApp(context, account.id.toString())
}
fun isUnifiedPushNotificationEnabledForAccount(account: AccountEntity): Boolean =
account.unifiedPushUrl.isNotEmpty()
private fun isUnifiedPushAvailable(context: Context): Boolean =
UnifiedPush.getDistributors(context).isNotEmpty()
fun canEnablePushNotifications(context: Context, accountManager: AccountManager): Boolean =
isUnifiedPushAvailable(context) && !anyAccountNeedsMigration(accountManager)
suspend fun enablePushNotificationsWithFallback(context: Context, api: MastodonApi, accountManager: AccountManager) {
if (!canEnablePushNotifications(context, accountManager)) {
// No UP distributors
NotificationHelper.enablePullNotifications(context)
return
}
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
accountManager.accounts.forEach {
val notificationGroupEnabled = Build.VERSION.SDK_INT < 28 ||
nm.getNotificationChannelGroup(it.identifier)?.isBlocked == false
val shouldEnable = it.notificationsEnabled && notificationGroupEnabled
if (shouldEnable) {
enableUnifiedPushNotificationsForAccount(context, api, accountManager, it)
} else {
disableUnifiedPushNotificationsForAccount(context, it)
}
}
}
private fun disablePushNotifications(context: Context, accountManager: AccountManager) {
accountManager.accounts.forEach {
disableUnifiedPushNotificationsForAccount(context, it)
}
}
fun disableAllNotifications(context: Context, accountManager: AccountManager) {
disablePushNotifications(context, accountManager)
NotificationHelper.disablePullNotifications(context)
}
private fun buildSubscriptionData(context: Context, account: AccountEntity): Map<String, Boolean> =
buildMap {
Notification.Type.asList.forEach {
put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(account, it, context))
}
}
// Called by UnifiedPush callback
suspend fun registerUnifiedPushEndpoint(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity, endpoint: String) {
// Generate a prime256v1 key pair for WebPush
// Decryption is unimplemented for now, since Mastodon uses an old WebPush
// standard which does not send needed information for decryption in the payload
// This makes it not directly compatible with UnifiedPush
// As of now, we use it purely as a way to trigger a pull
val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1)
val auth = CryptoUtil.secureRandomBytesEncoded(16)
withContext(Dispatchers.IO) {
api.subscribePushNotifications(
"Bearer ${account.accessToken}", account.domain,
endpoint, keyPair.pubkey, auth,
buildSubscriptionData(context, account)
).onFailure {
Log.d(TAG, "Error setting push endpoint for account ${account.id}")
Log.d(TAG, Log.getStackTraceString(it))
Log.d(TAG, (it as HttpException).response().toString())
disableUnifiedPushNotificationsForAccount(context, account)
}.onSuccess {
Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}")
account.pushPubKey = keyPair.pubkey
account.pushPrivKey = keyPair.privKey
account.pushAuth = auth
account.pushServerKey = it.serverKey
account.unifiedPushUrl = endpoint
accountManager.saveAccount(account)
}
}
}
// Synchronize the enabled / disabled state of notifications with server-side subscription
suspend fun updateUnifiedPushSubscription(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity) {
withContext(Dispatchers.IO) {
api.updatePushNotificationSubscription(
"Bearer ${account.accessToken}", account.domain,
buildSubscriptionData(context, account)
).onSuccess {
Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}")
account.pushServerKey = it.serverKey
accountManager.saveAccount(account)
}
}
}
suspend fun unregisterUnifiedPushEndpoint(api: MastodonApi, accountManager: AccountManager, account: AccountEntity) {
withContext(Dispatchers.IO) {
api.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain)
.onFailure {
Log.d(TAG, "Error unregistering push endpoint for account " + account.id)
Log.d(TAG, Log.getStackTraceString(it))
Log.d(TAG, (it as HttpException).response().toString())
}
.onSuccess {
Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id)
// Clear the URL in database
account.unifiedPushUrl = ""
account.pushServerKey = ""
account.pushAuth = ""
account.pushPrivKey = ""
account.pushPubKey = ""
accountManager.saveAccount(account)
}
}
}

View File

@ -23,6 +23,7 @@ import androidx.annotation.DrawableRes
import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.AccountListActivity
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.FiltersActivity
import com.keylesspalace.tusky.R
@ -30,6 +31,8 @@ import com.keylesspalace.tusky.TabPreferenceActivity
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigration
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
@ -139,6 +142,18 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
}
}
if (currentAccountNeedsMigration(accountManager)) {
preference {
setTitle(R.string.title_migration_relogin)
setIcon(R.drawable.ic_logout)
setOnPreferenceClickListener {
val intent = LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION)
(activity as BaseActivity).startActivityWithSlideInAnimation(intent)
true
}
}
}
preferenceCategory(R.string.pref_publishing) {
listPreference {
setTitle(R.string.pref_default_post_privacy)

View File

@ -64,7 +64,15 @@ data class AccountEntity(
var activeNotifications: String = "[]",
var emojis: List<Emoji> = emptyList(),
var tabPreferences: List<TabData> = defaultTabs(),
var notificationsFilter: String = "[\"follow_request\"]"
var notificationsFilter: String = "[\"follow_request\"]",
// Scope cannot be changed without re-login, so store it in case
// the scope needs to be changed in the future
var oauthScopes: String = "",
var unifiedPushUrl: String = "",
var pushPubKey: String = "",
var pushPrivKey: String = "",
var pushAuth: String = "",
var pushServerKey: String = "",
) {
val identifier: String

View File

@ -54,7 +54,7 @@ class AccountManager @Inject constructor(db: AppDatabase) {
* @param accessToken the access token for the new account
* @param domain the domain of the accounts Mastodon instance
*/
fun addAccount(accessToken: String, domain: String) {
fun addAccount(accessToken: String, domain: String, oauthScopes: String) {
activeAccount?.let {
it.isActive = false
@ -65,7 +65,10 @@ class AccountManager @Inject constructor(db: AppDatabase) {
val maxAccountId = accounts.maxByOrNull { it.id }?.id ?: 0
val newAccountId = maxAccountId + 1
activeAccount = AccountEntity(id = newAccountId, domain = domain.lowercase(Locale.ROOT), accessToken = accessToken, isActive = true)
activeAccount = AccountEntity(
id = newAccountId, domain = domain.lowercase(Locale.ROOT),
accessToken = accessToken, oauthScopes = oauthScopes, isActive = true
)
}
/**
@ -189,4 +192,15 @@ class AccountManager @Inject constructor(db: AppDatabase) {
id == accountId
}
}
/**
* Finds an account by its string identifier
* @param identifier the string identifier of the account
* @return the requested account or null if it was not found
*/
fun getAccountByIdentifier(identifier: String): AccountEntity? {
return accounts.find {
identifier == it.identifier
}
}
}

View File

@ -31,7 +31,7 @@ import java.io.File;
*/
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class
}, version = 35)
}, version = 36)
public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao();
@ -542,4 +542,16 @@ public abstract class AppDatabase extends RoomDatabase {
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `card` TEXT");
}
};
public static final Migration MIGRATION_35_36 = new Migration(35, 36) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `oauthScopes` TEXT NOT NULL DEFAULT ''");
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `unifiedPushUrl` TEXT NOT NULL DEFAULT ''");
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushPubKey` TEXT NOT NULL DEFAULT ''");
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushPrivKey` TEXT NOT NULL DEFAULT ''");
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushAuth` TEXT NOT NULL DEFAULT ''");
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushServerKey` TEXT NOT NULL DEFAULT ''");
}
};
}

View File

@ -70,6 +70,7 @@ class AppModule {
AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29,
AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32,
AppDatabase.MIGRATION_32_33, AppDatabase.MIGRATION_33_34, AppDatabase.MIGRATION_34_35,
AppDatabase.MIGRATION_35_36,
)
.build()
}

View File

@ -16,8 +16,10 @@
package com.keylesspalace.tusky.di
import com.keylesspalace.tusky.receiver.NotificationBlockStateBroadcastReceiver
import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver
import com.keylesspalace.tusky.receiver.UnifiedPushBroadcastReceiver
import dagger.Module
import dagger.android.ContributesAndroidInjector
@ -28,4 +30,10 @@ abstract class BroadcastReceiverModule {
@ContributesAndroidInjector
abstract fun contributeNotificationClearBroadcastReceiver(): NotificationClearBroadcastReceiver
@ContributesAndroidInjector
abstract fun contributeUnifiedPushBroadcastReceiver(): UnifiedPushBroadcastReceiver
@ContributesAndroidInjector
abstract fun contributeNotificationBlockStateBroadcastReceiver(): NotificationBlockStateBroadcastReceiver
}

View File

@ -23,6 +23,7 @@ data class Account(
@SerializedName("username") val localUsername: String,
@SerializedName("acct", alternate = ["subject"]) val username: String,
@SerializedName("display_name") private val displayName: String?, // should never be null per Api definition, but some servers break the contract
@SerializedName("created_at") val createdAt: Date,
val note: String,
val url: String,
val avatar: String,

View File

@ -0,0 +1,24 @@
/* Copyright 2022 Tusky contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.entity
import com.google.gson.annotations.SerializedName
data class NotificationSubscribeResult(
val id: Int,
val endpoint: String,
@SerializedName("server_key") val serverKey: String,
)

View File

@ -30,6 +30,7 @@ import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.entity.MediaUploadResult
import com.keylesspalace.tusky.entity.NewStatus
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.NotificationSubscribeResult
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.ScheduledStatus
@ -47,6 +48,7 @@ import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.Field
import retrofit2.http.FieldMap
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.HTTP
@ -286,6 +288,14 @@ interface MastodonApi {
@Query("following") following: Boolean? = null
): Single<List<TimelineAccount>>
@GET("api/v1/accounts/search")
fun searchAccountsCall(
@Query("q") query: String,
@Query("resolve") resolve: Boolean? = null,
@Query("limit") limit: Int? = null,
@Query("following") following: Boolean? = null
): Call<List<TimelineAccount>>
@GET("api/v1/accounts/{id}")
fun account(
@Path("id") accountId: String
@ -591,10 +601,48 @@ interface MastodonApi {
@Query("following") following: Boolean? = null
): Single<SearchResult>
@GET("api/v2/search")
fun searchCall(
@Query("q") query: String?,
@Query("type") type: String? = null,
@Query("resolve") resolve: Boolean? = null,
@Query("limit") limit: Int? = null,
@Query("offset") offset: Int? = null,
@Query("following") following: Boolean? = null
): Call<SearchResult>
@FormUrlEncoded
@POST("api/v1/accounts/{id}/note")
fun updateAccountNote(
@Path("id") accountId: String,
@Field("comment") note: String
): Single<Relationship>
@FormUrlEncoded
@POST("api/v1/push/subscription")
suspend fun subscribePushNotifications(
@Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String,
@Field("subscription[endpoint]") endPoint: String,
@Field("subscription[keys][p256dh]") keysP256DH: String,
@Field("subscription[keys][auth]") keysAuth: String,
// The "data[alerts][]" fields to enable / disable notifications
// Should be generated dynamically from all the available notification
// types defined in [com.keylesspalace.tusky.entities.Notification.Types]
@FieldMap data: Map<String, Boolean>
): Result<NotificationSubscribeResult>
@FormUrlEncoded
@PUT("api/v1/push/subscription")
suspend fun updatePushNotificationSubscription(
@Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String,
@FieldMap data: Map<String, Boolean>
): Result<NotificationSubscribeResult>
@DELETE("api/v1/push/subscription")
suspend fun unsubscribePushNotifications(
@Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String,
): Result<ResponseBody>
}

View File

@ -0,0 +1,67 @@
/* Copyright 2022 Tusky contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.receiver
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import com.keylesspalace.tusky.components.notifications.canEnablePushNotifications
import com.keylesspalace.tusky.components.notifications.isUnifiedPushNotificationEnabledForAccount
import com.keylesspalace.tusky.components.notifications.updateUnifiedPushSubscription
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.network.MastodonApi
import dagger.android.AndroidInjection
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@DelicateCoroutinesApi
class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() {
@Inject
lateinit var mastodonApi: MastodonApi
@Inject
lateinit var accountManager: AccountManager
override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context)
if (Build.VERSION.SDK_INT < 28) return
if (!canEnablePushNotifications(context, accountManager)) return
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val gid = when (intent.action) {
NotificationManager.ACTION_NOTIFICATION_CHANNEL_BLOCK_STATE_CHANGED -> {
val channelId = intent.getStringExtra(NotificationManager.EXTRA_NOTIFICATION_CHANNEL_ID)
nm.getNotificationChannel(channelId).group
}
NotificationManager.ACTION_NOTIFICATION_CHANNEL_GROUP_BLOCK_STATE_CHANGED -> {
intent.getStringExtra(NotificationManager.EXTRA_NOTIFICATION_CHANNEL_GROUP_ID)
}
else -> null
} ?: return
accountManager.getAccountByIdentifier(gid)?.let { account ->
if (isUnifiedPushNotificationEnabledForAccount(account)) {
// Update UnifiedPush notification subscription
GlobalScope.launch { updateUnifiedPushSubscription(context, mastodonApi, accountManager, account) }
}
}
}
}

View File

@ -0,0 +1,80 @@
/* Copyright 2022 Tusky contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.receiver
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import com.keylesspalace.tusky.components.notifications.NotificationWorker
import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint
import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.network.MastodonApi
import dagger.android.AndroidInjection
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.unifiedpush.android.connector.MessagingReceiver
import javax.inject.Inject
@DelicateCoroutinesApi
class UnifiedPushBroadcastReceiver : MessagingReceiver() {
companion object {
const val TAG = "UnifiedPush"
}
@Inject
lateinit var accountManager: AccountManager
@Inject
lateinit var mastodonApi: MastodonApi
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
AndroidInjection.inject(this, context)
}
override fun onMessage(context: Context, message: ByteArray, instance: String) {
AndroidInjection.inject(this, context)
Log.d(TAG, "New message received for account $instance")
val workManager = WorkManager.getInstance(context)
val request = OneTimeWorkRequest.from(NotificationWorker::class.java)
workManager.enqueue(request)
}
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
AndroidInjection.inject(this, context)
Log.d(TAG, "Endpoint available for account $instance: $endpoint")
accountManager.getAccountById(instance.toLong())?.let {
// Launch the coroutine in global scope -- it is short and we don't want to lose the registration event
// and there is no saner way to use structured concurrency in a receiver
GlobalScope.launch { registerUnifiedPushEndpoint(context, mastodonApi, accountManager, it, endpoint) }
}
}
override fun onRegistrationFailed(context: Context, instance: String) = Unit
override fun onUnregistered(context: Context, instance: String) {
AndroidInjection.inject(this, context)
Log.d(TAG, "Endpoint unregistered for account $instance")
accountManager.getAccountById(instance.toLong())?.let {
// It's fine if the account does not exist anymore -- that means it has been logged out
GlobalScope.launch { unregisterUnifiedPushEndpoint(mastodonApi, accountManager, it) }
}
}
}

View File

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

View File

@ -0,0 +1,60 @@
/* Copyright 2022 Tusky contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.util
import android.util.Base64
import org.bouncycastle.jce.ECNamedCurveTable
import org.bouncycastle.jce.interfaces.ECPrivateKey
import org.bouncycastle.jce.interfaces.ECPublicKey
import org.bouncycastle.jce.provider.BouncyCastleProvider
import java.security.KeyPairGenerator
import java.security.SecureRandom
import java.security.Security
object CryptoUtil {
const val CURVE_PRIME256_V1 = "prime256v1"
private const val BASE64_FLAGS = Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
init {
Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME)
Security.addProvider(BouncyCastleProvider())
}
private fun secureRandomBytes(len: Int): ByteArray {
val ret = ByteArray(len)
SecureRandom.getInstance("SHA1PRNG").nextBytes(ret)
return ret
}
fun secureRandomBytesEncoded(len: Int): String {
return Base64.encodeToString(secureRandomBytes(len), BASE64_FLAGS)
}
data class EncodedKeyPair(val pubkey: String, val privKey: String)
fun generateECKeyPair(curve: String): EncodedKeyPair {
val spec = ECNamedCurveTable.getParameterSpec(curve)
val gen = KeyPairGenerator.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME)
gen.initialize(spec)
val keyPair = gen.genKeyPair()
val pubKey = keyPair.public as ECPublicKey
val privKey = keyPair.private as ECPrivateKey
val encodedPubKey = Base64.encodeToString(pubKey.q.getEncoded(false), BASE64_FLAGS)
val encodedPrivKey = Base64.encodeToString(privKey.d.toByteArray(), BASE64_FLAGS)
return EncodedKeyPair(encodedPubKey, encodedPrivKey)
}
}

View File

@ -98,7 +98,7 @@ public class AccessTokenLoginActivity extends AppCompatActivity implements Injec
}
private void authSucceeded(String domain, String accessToken) {
accountManager.addAccount(accessToken, domain);
accountManager.addAccount(accessToken, domain, "");
log("Completed. Enjoy!");
Intent intent = new Intent(this, MainActivity.class);

View File

@ -235,6 +235,19 @@
tools:itemCount="2"
tools:listitem="@layout/item_account_field" />
<TextView
android:id="@+id/accountDateJoined"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
tools:text="April, 1971"
android:textColor="@color/textColorSecondary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountFieldList"
app:layout_constraintBottom_toTopOf="@id/accountRemoveView"/>
<TextView
android:id="@+id/accountRemoveView"
android:layout_width="match_parent"
@ -245,7 +258,7 @@
android:lineSpacingMultiplier="1.1"
android:text="@string/label_remote_account"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/accountFieldList"
app:layout_constraintTop_toBottomOf="@id/accountDateJoined"
tools:visibility="visible" />
<androidx.constraintlayout.widget.ConstraintLayout

View File

@ -16,8 +16,8 @@
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:contentInsetStartWithNavigation="0dp"
android:elevation="@dimen/actionbar_elevation"
app:contentInsetStartWithNavigation="0dp"
app:layout_scrollFlags="scroll|snap|enterAlways"
app:navigationIcon="?attr/homeAsUpIndicator" />
@ -27,8 +27,9 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabGravity="fill"
app:tabMaxWidth="0dp"
app:tabMode="fixed"
app:tabTextAppearance="@style/TuskyTabAppearance"/>
app:tabTextAppearance="@style/TuskyTabAppearance" />
</com.google.android.material.appbar.AppBarLayout>
@ -38,6 +39,6 @@
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<include layout="@layout/item_status_bottom_sheet"/>
<include layout="@layout/item_status_bottom_sheet" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -1,48 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:padding="8dp">
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp">
<ImageView
android:id="@+id/avatar"
android:layout_width="42dp"
android:layout_height="42dp"
android:layout_centerVertical="true"
android:layout_marginEnd="8dp"
android:contentDescription="@null"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="24dp"
android:foregroundGravity="center_vertical"
android:importantForAccessibility="no"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/avatar_default" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_toEndOf="@id/avatar"
android:gravity="center_vertical"
android:orientation="vertical">
<ImageView
android:id="@+id/avatarBadge"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/profile_badge_bot_text"
android:src="@drawable/bot_badge"
app:layout_constraintBottom_toBottomOf="@id/avatar"
app:layout_constraintEnd_toEndOf="@id/avatar" />
<TextView
android:id="@+id/display_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_large"
android:textStyle="normal|bold"
tools:text="Conny Duck" />
<TextView
android:id="@+id/displayName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_large"
android:textStyle="normal|bold"
app:layout_constraintBottom_toTopOf="@id/username"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintTop_toTopOf="@id/avatar"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Display name" />
<TextView
android:id="@+id/username"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium"
tools:text="\@ConnyDuck" />
<TextView
android:id="@+id/username"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium"
app:layout_constraintBottom_toBottomOf="@id/avatar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintTop_toBottomOf="@id/displayName"
tools:text="\@username" />
</LinearLayout>
</RelativeLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<View xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/status_divider" />

View File

@ -5,24 +5,24 @@
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="8dp">
tools:ignore="UseCompoundDrawables">
<ImageView
android:id="@+id/preview"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="4dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="4dp"
android:contentDescription="@null"
android:padding="4dp" />
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/shortcode"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorPrimary"

View File

@ -1,11 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/hashtag"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:ellipsize="end"
android:maxLines="1"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:textSize="?attr/status_text_medium"
android:textStyle="normal|bold"
app:drawableStartCompat="@drawable/ic_list"
app:drawableTint="?attr/iconColor" />
tools:text="#Tusky" />

View File

@ -269,7 +269,6 @@
<string name="add_account_description">إضافة حساب ماستدون جديد</string>
<string name="action_lists">القوائم</string>
<string name="title_lists">القوائم</string>
<string name="title_list_timeline">الخط الزمني للقائمة</string>
<string name="error_create_list">لا يمكن إنشاء قائمة</string>
<string name="error_rename_list">لا يمكن إعادة تسمية القائمة</string>
<string name="error_delete_list">لا يمكن حذف القائمة</string>

View File

@ -151,7 +151,6 @@
<string name="error_delete_list">Списъкът не можа да се изтрие</string>
<string name="error_create_list">Списъкът не можа да се създаде</string>
<string name="error_rename_list">Списъкът не можа да се преименува</string>
<string name="title_list_timeline">Списъчна емисия</string>
<string name="title_lists">Списъци</string>
<string name="action_lists">Списъци</string>
<string name="add_account_description">Добавяне на нов Mastodon акаунт</string>

View File

@ -75,7 +75,6 @@
<string name="error_delete_list">তালিকা মুছে ফেলা যায়নি</string>
<string name="error_rename_list">তালিকা নামকরণ করা যায়নি</string>
<string name="error_create_list">তালিকা তৈরি করা যায়নি</string>
<string name="title_list_timeline">তালিকা টাইমলাইনে রাখুন</string>
<string name="title_lists">তালিকাসমূহ</string>
<string name="action_lists">তালিকাসমূহ</string>
<string name="add_account_description">নতুন ম্যাস্টোডোন অ্যাকাউন্ট যোগ করুন</string>

View File

@ -275,7 +275,6 @@
<string name="add_account_description">নতুন ম্যাস্টোডোন অ্যাকাউন্ট যোগ করুন</string>
<string name="action_lists">তালিকাসমূহ</string>
<string name="title_lists">তালিকাসমূহ</string>
<string name="title_list_timeline">তালিকা টাইমলাইনে রাখুন</string>
<string name="error_create_list">তালিকা তৈরি করা যায়নি</string>
<string name="error_rename_list">তালিকা নামকরণ করা যায়নি</string>
<string name="error_delete_list">তালিকা মুছে ফেলা যায়নি</string>

View File

@ -275,7 +275,6 @@
<string name="add_account_description">Afegir un compte de Mastodont</string>
<string name="action_lists">Llistes</string>
<string name="title_lists">Llistes</string>
<string name="title_list_timeline">Cronologia de la llista</string>
<string name="error_create_list">És impossible crear la llista</string>
<string name="error_rename_list">Impossible reanomenar la llista</string>
<string name="error_delete_list">És impossible suprimir la llista</string>

View File

@ -403,7 +403,6 @@
<string name="error_delete_list">نەیتوانی لیستەکە بسڕێتەوە</string>
<string name="error_rename_list">نەیتوانی ناوی لیست بنووسرێ</string>
<string name="error_create_list">نەیتوانی لیست دروست بکات</string>
<string name="title_list_timeline">لیستی تایم لاین</string>
<string name="title_lists">لیستەکان</string>
<string name="action_lists">لیستەکان</string>
<string name="add_account_description">زیادکردنی ئەژمێری ماتۆدۆنی نوێ</string>

View File

@ -274,7 +274,6 @@
<string name="add_account_description">Přidat nový účet Mastodon</string>
<string name="action_lists">Seznamy</string>
<string name="title_lists">Seznamy</string>
<string name="title_list_timeline">Časová osa seznamu</string>
<string name="error_create_list">Nelze vytvořit seznam</string>
<string name="error_rename_list">Nelze přejmenovat seznam</string>
<string name="error_delete_list">Nelze smazat seznam</string>

View File

@ -236,7 +236,6 @@
<string name="add_account_description">Ychwanegu cyfrif Mastodon newydd</string>
<string name="action_lists">Rhestri</string>
<string name="title_lists">Rhestri</string>
<string name="title_list_timeline">Amserlen rhestri</string>
<string name="compose_active_account_description">Yn postio â chyfrif %1$s</string>
<string name="error_failed_set_caption">Methu gosod pennawd</string>
<string name="action_set_caption">Pennu pennawd</string>

View File

@ -256,7 +256,6 @@
<string name="add_account_description">Neues Mastodon-Konto hinzufügen</string>
<string name="action_lists">Listen</string>
<string name="title_lists">Listen</string>
<string name="title_list_timeline">Liste</string>
<string name="action_create_list">Liste erstellen</string>
<string name="action_rename_list">Liste umbenennen</string>
<string name="action_delete_list">Liste löschen</string>

View File

@ -271,7 +271,6 @@
<string name="add_account_description">Aldoni novan Mastodon konton</string>
<string name="action_lists">Listoj</string>
<string name="title_lists">Listoj</string>
<string name="title_list_timeline">Tempolinio de la listo</string>
<string name="error_create_list">Ne povis krei la liston</string>
<string name="error_rename_list">Ne povis ŝanĝi la nomon de la listo</string>
<string name="error_delete_list">Ne povis forigi la liston</string>

View File

@ -251,7 +251,6 @@
<string name="add_account_description">Añadir cuenta de Mastodon</string>
<string name="action_lists">Listas</string>
<string name="title_lists">Listas</string>
<string name="title_list_timeline">Cronología de lista</string>
<string name="compose_active_account_description">Publicando con la cuenta %1$s</string>
<string name="error_failed_set_caption">Error al añadir leyenda</string>
<plurals name="hint_describe_for_visually_impaired">

View File

@ -235,7 +235,6 @@
<string name="add_account_description">Mastodon kontua gehitu</string>
<string name="action_lists">Zerrendak</string>
<string name="title_lists">Zerrendak</string>
<string name="title_list_timeline">Zerrenda denbora-lerroa</string>
<string name="compose_active_account_description">%1$s kontuarekin tut egiten</string>
<string name="error_failed_set_caption">Akatsa deskribapena eranstean</string>
<plurals name="hint_describe_for_visually_impaired">

View File

@ -229,7 +229,6 @@
<string name="add_account_description">افزودن حساب ماستودون جدید</string>
<string name="action_lists">فهرست‌ها</string>
<string name="title_lists">فهرست‌ها</string>
<string name="title_list_timeline">خط زمانی فهرست</string>
<string name="compose_active_account_description">در حال فرستادن با حساب %1$s</string>
<string name="error_failed_set_caption">شکست در تنظیم عنوان</string>
<plurals name="hint_describe_for_visually_impaired">

View File

@ -275,7 +275,6 @@
<string name="add_account_description">Ajouter un nouveau compte Mastodon</string>
<string name="action_lists">Listes</string>
<string name="title_lists">Listes</string>
<string name="title_list_timeline">Fil de la liste</string>
<string name="error_create_list">Impossible de créer la liste</string>
<string name="error_rename_list">Impossible de renommer la liste</string>
<string name="error_delete_list">Impossible de supprimer la liste</string>
@ -295,7 +294,7 @@
<string name="action_set_caption">Mettre une légende</string>
<string name="action_remove">Supprimer le média</string>
<string name="lock_account_label">Verrouiller le compte</string>
<string name="lock_account_label_description">Vous devez approuvez manuellement les abonnements</string>
<string name="lock_account_label_description">Vous devez approuver manuellement les abonnements</string>
<string name="compose_save_draft">Enregistrer comme brouillon ?</string>
<string name="send_post_notification_title">Envoi du pouet…</string>
<string name="send_post_notification_error_title">Erreur lors de lenvoi du pouet</string>

View File

@ -310,7 +310,6 @@
<string name="failed_fetch_posts">Theip ar stádas a fháil</string>
<string name="report_description_1">Seolfar an tuarascáil chuig do mhodhnóir freastalaí. Féadfaidh tú míniú a thabhairt ar an bhfáth go bhfuil tú ag tuairisciú an chuntais seo thíos:</string>
<string name="add_account_description">Cuir Cuntas Mastodon nua leis</string>
<string name="title_list_timeline">Liostaigh amlíne</string>
<string name="error_create_list">Níorbh fhéidir liosta a chruthú</string>
<string name="error_rename_list">Níorbh fhéidir an liosta a athainmniú</string>
<string name="error_delete_list">Níorbh fhéidir an liosta a scriosadh</string>

View File

@ -92,7 +92,7 @@
<string name="notification_subscription_description">Brathan nuair a dhfhoillsich cuideigin air a rinn mi fo-sgrìobhadh post ùr</string>
<string name="notification_subscription_name">Postaichean ùra</string>
<string name="pref_title_notification_filter_subscriptions">dhfhoillsich cuideigin air a rinn mi fo-sgrìobhadh post ùr</string>
<string name="notification_subscription_format">Tha %s air rud a phostadh</string>
<string name="notification_subscription_format">Phostaich %s rud</string>
<string name="no_announcements">Chan eil brath-fios ann.</string>
<string name="title_announcements">Brathan-fios</string>
<string name="account_note_saved">Chaidh a shàbhaladh!</string>
@ -270,7 +270,6 @@
<string name="error_delete_list">Cha b urrainn dhuinn an liosta a sguabadh às</string>
<string name="error_rename_list">Cha b urrainn dhut ainm ùr a thoirt air an liosta</string>
<string name="error_create_list">Cha b urrainn dhuinn an liosta a chruthachadh</string>
<string name="title_list_timeline">Loidhne-ama na liosta</string>
<string name="add_account_description">Cuir cunntas Mastodon ùr ris</string>
<string name="add_account_name">Cuir cunntas ris</string>
<string name="filter_add_description">An abairt ri chriathradh</string>
@ -295,7 +294,7 @@
<string name="action_schedule_post">Cuir post air an sgeideal</string>
<string name="action_toggle_visibility">Faicsinneachd a phuist</string>
<string name="action_access_scheduled_posts">Postaichean air an sgeideal</string>
<string name="notification_favourite_format">Chuir %s am post agad ris na h-annsachdan</string>
<string name="notification_favourite_format">Is annsa le %s am post agad</string>
<string name="notification_reblog_format">Bhrosnaich %s am post agad</string>
<string name="title_scheduled_posts">Postaichean air an sgeideal</string>
<string name="title_view_thread">Snàithlean</string>
@ -556,4 +555,5 @@
<string name="pref_title_notification_filter_updates">chaidh post a rinn mi conaltradh leis a deasachadh</string>
<string name="title_login">Clàraich a-steach</string>
<string name="error_could_not_load_login_page">Cha b urrainn dhuinn duilleag a chlàraidh a-steach fhosgladh.</string>
<string name="saving_draft">A sàbhaladh na dreuchd…</string>
</resources>

View File

@ -275,7 +275,6 @@
<string name="error_delete_list">Non se puido eliminar a listaxe</string>
<string name="error_rename_list">Non se puido renomear a listaxe</string>
<string name="error_create_list">Non se puido crear a listaxe</string>
<string name="title_list_timeline">Cronoloxía da listaxe</string>
<string name="title_lists">Listaxes</string>
<string name="action_lists">Listaxes</string>
<string name="add_account_description">Engadir unha nova conta Mastodon</string>

View File

@ -248,7 +248,6 @@
<string name="compose_save_draft">लिखने को सुरक्षित करें\?</string>
<string name="lock_account_label">खाता लॉक करें</string>
<string name="action_set_caption">कैप्शन सेट करें</string>
<string name="title_list_timeline">सूची टाइमलाइन</string>
<string name="add_account_name">खाता जोड़ो</string>
<string name="filter_dialog_whole_word">पूरा शब्द</string>
<string name="filter_edit_dialog_title">फ़िल्टर संपादित करें</string>

View File

@ -336,7 +336,6 @@
<string name="abbreviated_in_seconds">%dmp múlva</string>
<string name="filter_dialog_whole_word">Teljes szó</string>
<string name="filter_dialog_whole_word_description">Ha a kulcsszó csak alfanumerikus karakterekből áll, csak teljes szóra fog illeszkedni</string>
<string name="title_list_timeline">Lista idővonal</string>
<string name="hint_search_people_list">Általad követettek keresése</string>
<string name="action_add_to_list">Fiók hozzáadása a listához</string>
<string name="action_remove_from_list">Fiók eltávolítása a listából</string>

View File

@ -288,7 +288,6 @@
<string name="filter_add_description">Frasi sem á að sía</string>
<string name="add_account_name">Bæta við aðgang</string>
<string name="add_account_description">Bæta við nýjum Mastodon-aðgangi</string>
<string name="title_list_timeline">Lista upp tímalínu</string>
<string name="error_create_list">Ekki tókst að búa til lista</string>
<string name="error_rename_list">Ekki tókst að endurnefna lista</string>
<string name="error_delete_list">Ekki tókst að eyða lista</string>

View File

@ -269,7 +269,6 @@
<string name="add_account_description">Aggiungi un nuovo Account Mastodon</string>
<string name="action_lists">Liste</string>
<string name="title_lists">Liste</string>
<string name="title_list_timeline">Timeline della lista</string>
<string name="error_create_list">Non è stato possibile creare la lista</string>
<string name="error_rename_list">Non è stato possibile rinominare la lista</string>
<string name="error_delete_list">Non è stato possibile eliminare la lista</string>

View File

@ -264,7 +264,6 @@
<string name="add_account_description">新しいMastodonアカウントを追加</string>
<string name="action_lists">リスト</string>
<string name="title_lists">リスト</string>
<string name="title_list_timeline">リストタイムライン</string>
<string name="error_rename_list">リスト名を変更できませんでした</string>
<string name="action_rename_list">リスト名の変更</string>
<string name="compose_active_account_description">%1$sで投稿</string>

View File

@ -282,7 +282,6 @@
<string name="add_account_description">마스토돈 계정을 추가합니다</string>
<string name="action_lists">리스트</string>
<string name="title_lists">리스트</string>
<string name="title_list_timeline">리스트 타임라인</string>
<string name="error_create_list">리스트를 만들 수 없습니다.</string>
<string name="error_rename_list">리스트의 이름을 변경할 수 없습니다.</string>
<string name="error_delete_list">리스트를 삭제할 수 없습니다.</string>

View File

@ -259,7 +259,6 @@
<string name="add_account_description">Een nieuw Mastodonaccount toevoegen</string>
<string name="action_lists">Lijsten</string>
<string name="title_lists">Lijsten</string>
<string name="title_list_timeline">Tijdlijn lijst</string>
<string name="compose_active_account_description">Aan het publiceren met account %1$s</string>
<string name="error_failed_set_caption">Toevoegen van beschrijving mislukt</string>
<plurals name="hint_describe_for_visually_impaired">

View File

@ -242,7 +242,6 @@
<string name="add_account_description">Legg til ny Mastodon-konto</string>
<string name="action_lists">Lister</string>
<string name="title_lists">Lister</string>
<string name="title_list_timeline">Listetidslinje</string>
<string name="error_create_list">Kunne ikke opprette liste</string>
<string name="error_rename_list">Kunne ikke gi liste nytt navn</string>
<string name="error_delete_list">Kunne ikke slette liste</string>

View File

@ -228,7 +228,6 @@
<string name="add_account_description">Apondre un nòu compte Mastodon</string>
<string name="action_lists">Listas</string>
<string name="title_lists">Listas</string>
<string name="title_list_timeline">Flux de la lista</string>
<string name="compose_active_account_description">Publicar amb lo compte %1$s</string>
<string name="error_failed_set_caption">Fracàs en apondre una legenda</string>
<string name="action_set_caption">Apondre una legenda</string>

View File

@ -20,7 +20,7 @@
<string name="title_home">Strona główna</string>
<string name="title_notifications">Powiadomienia</string>
<string name="title_public_local">Lokalne</string>
<string name="title_public_federated">Globalne</string>
<string name="title_public_federated">Sfederowane</string>
<string name="title_view_thread">Wątek</string>
<string name="title_posts">Wpisy</string>
<string name="title_posts_with_replies">Z odpowiedziami</string>
@ -33,12 +33,12 @@
<string name="title_edit_profile">Edytuj profil</string>
<string name="title_drafts">Szkice</string>
<string name="title_licenses">Licencje</string>
<string name="post_boosted_format">%s podbił</string>
<string name="post_sensitive_media_title">Wrażliwe treści</string>
<string name="post_media_hidden_title">Ukryto zawartość multimedialną</string>
<string name="post_boosted_format">%s podbite</string>
<string name="post_sensitive_media_title">Treści wrażliwe</string>
<string name="post_media_hidden_title">Ukryto multimedia</string>
<string name="post_sensitive_media_directions">Naciśnij, aby wyświetlić</string>
<string name="post_content_warning_show_more">Pokaż więcej</string>
<string name="post_content_warning_show_less">Ukryj</string>
<string name="post_content_warning_show_less">Pokaż mniej</string>
<string name="footer_empty">Pusto tutaj. Pociągnij, aby odświeżyć!</string>
<string name="notification_reblog_format">%s podbił(-a) Twój wpis</string>
<string name="notification_favourite_format">%s dodał Twój post do ulubionych</string>
@ -95,13 +95,13 @@
<string name="action_emoji_keyboard">Klawiatura emoji</string>
<string name="download_image">Pobieranie %1$s</string>
<string name="action_copy_link">Skopiuj odnośnik</string>
<string name="send_post_link_to">Udostępnij odnośnik do wpisu</string>
<string name="send_post_link_to">Udostępnij URL do</string>
<string name="send_post_content_to">Udostępnij wpis do…</string>
<string name="confirmation_reported">Wyślij!</string>
<string name="confirmation_unblocked">Odblokowano użytkownika</string>
<string name="confirmation_unmuted">Cofnięto wyciszenie użytkownika</string>
<string name="post_sent">Wyślij!</string>
<string name="post_sent_long">Pomyślnie wysłano odpowiedź.</string>
<string name="post_sent_long">Odpowiedź wysłano pomyślnie.</string>
<string name="hint_domain">Jaka instancja?</string>
<string name="hint_compose">Co Ci chodzi po głowie?</string>
<string name="hint_content_warning">Ostrzeżenie o zawartości</string>
@ -151,7 +151,7 @@
<string name="pref_title_custom_tabs">Używaj niestandardowych kart Chrome</string>
<string name="pref_title_hide_follow_button">Ukryj przycisk śledzenia podczas przewijania</string>
<string name="pref_title_post_filter">Filtrowanie osi czasu</string>
<string name="pref_title_post_tabs">Zakładki</string>
<string name="pref_title_post_tabs">Karty</string>
<string name="pref_title_show_boosts">Pokaż podbicia</string>
<string name="pref_title_show_replies">Pokazuj odpowiedzi</string>
<string name="pref_title_show_media_preview">Pokazuj podgląd zawartości multimedialnej</string>
@ -183,10 +183,10 @@
<string name="notification_summary_medium">%1$s, %2$s, i %3$s</string>
<string name="notification_summary_small">%1$s i %2$s</string>
<plurals name="notification_title_summary">
<item quantity="one">%d nowe powiadomienie</item>
<item quantity="few">%d nowe powiadomienia</item>
<item quantity="many">%d nowych powiadomień</item>
<item quantity="other">%d nowych powiadomień</item>
<item quantity="one">%d nowa interakcja</item>
<item quantity="few">%d nowe interakcje</item>
<item quantity="many">%d nowych interakcji</item>
<item quantity="other">%d nowych interakcji</item>
</plurals>
<string name="description_account_locked">Konto zablokowane</string>
<string name="about_title_activity">O programie</string>
@ -229,7 +229,6 @@
<string name="add_account_description">Dodaj nowe Konto Mastodon</string>
<string name="action_lists">Listy</string>
<string name="title_lists">Listy</string>
<string name="title_list_timeline">Oś czasu listy</string>
<string name="compose_active_account_description">Publikowanie z konta %1$s</string>
<string name="error_failed_set_caption">Nie udało się ustawić podpisu</string>
<string name="action_set_caption">Ustaw podpis</string>
@ -404,25 +403,25 @@
<string name="poll_ended_voted">Głosowanie w którym brałeś(-aś) udział zakończyła się</string>
<string name="poll_ended_created">Ankieta, którą stworzyłeś(aś), zakończyła się</string>
<plurals name="poll_timespan_days">
<item quantity="one">Zostało %d dzień</item>
<item quantity="one">Został %d dzień</item>
<item quantity="few">Zostało %d dni</item>
<item quantity="many">Zostało %d dni</item>
<item quantity="other">Zostało %d dni</item>
</plurals>
<plurals name="poll_timespan_hours">
<item quantity="one">Zostało %d godzina</item>
<item quantity="one">Została %d godzina</item>
<item quantity="few">Zostało %d godziny</item>
<item quantity="many">Zostało %d godzin</item>
<item quantity="other">Zostało %d godzin</item>
</plurals>
<plurals name="poll_timespan_minutes">
<item quantity="one">Zostało %d minuta</item>
<item quantity="one">Została %d minuta</item>
<item quantity="few">Zostało %d minuty</item>
<item quantity="many">Zostało %d minut</item>
<item quantity="other">Zostało %d minut</item>
</plurals>
<plurals name="poll_timespan_seconds">
<item quantity="one">Zostało %d sekunda</item>
<item quantity="one">Została %d sekunda</item>
<item quantity="few">Zostało %d sekund</item>
<item quantity="many">Zostało %d sekund</item>
<item quantity="other">Zostało %d sekund</item>
@ -462,7 +461,7 @@
<string name="title_bookmarks">Zakładki</string>
<string name="action_bookmark">Dodaj do zakładek</string>
<string name="action_view_bookmarks">Zakładki</string>
<string name="description_post_bookmarked">Dodane do zakładek</string>
<string name="description_post_bookmarked">Dodany do zakładek</string>
<string name="select_list_title">Wybierz listę</string>
<string name="list">Lista</string>
<string name="error_audio_upload_size">Pliki audio muszą być mniejsze niż 40MB.</string>
@ -493,8 +492,8 @@
<string name="pref_main_nav_position_option_bottom">Dół</string>
<string name="pref_main_nav_position_option_top">Góra</string>
<plurals name="error_upload_max_media_reached">
<item quantity="one">Nie możesz przesłać więcej niż %1$d załącznika.</item>
<item quantity="few">Nie możesz przesłać więcej niż %1$d załączników.</item>
<item quantity="one">Nie możesz przesłać więcej niż %1$d załącznik.</item>
<item quantity="few">Nie możesz przesłać więcej niż %1$d załączniki.</item>
<item quantity="many">Nie możesz przesłać więcej niż %1$d załączników.</item>
<item quantity="other">Nie możesz przesłać więcej niż %1$d załączników.</item>
</plurals>
@ -515,21 +514,21 @@
<string name="pref_title_enable_swipe_for_tabs">Włącz gest przesuwania by przełączać między zakładkami</string>
<string name="post_media_attachments">Załączniki</string>
<string name="notification_follow_request_description">Powiadomienia o prośbach o obserwowanie</string>
<string name="pref_title_notification_filter_subscriptions">ktoś kogo zasubskrybowałem/zasubskrybowałam opublikował nowy wpis</string>
<string name="pref_title_notification_filter_subscriptions">ktoś zasubskrybowany opublikował nowy wpis</string>
<string name="pref_title_notification_filter_follow_requests">Wysłano prośbę o obserwowanie</string>
<string name="title_announcements">Ogłoszenia</string>
<string name="pref_title_wellbeing_mode">Zdrowie</string>
<string name="action_unsubscribe_account">Anuluj subskrypcję</string>
<string name="action_subscribe_account">Zasubskrybuj</string>
<string name="follow_requests_info">Mimo tego, że twoje konto nie jest zablokowane, administracja %1$s uznała, że możesz chcieć ręcznie przejrzeć te prośby o możliwość śledzenia od tych kont.</string>
<string name="drafts_post_reply_removed">Wpis dla którego naszkicowałeś/naszkicowałaś odpowiedź został usunięty</string>
<string name="drafts_post_reply_removed">Wpis dla którego naszkicowałeś/aś odpowiedź został usunięty</string>
<string name="draft_deleted">Usunięto szkic</string>
<string name="wellbeing_hide_stats_profile">Ukryj ilościowe statystyki na profilach</string>
<string name="wellbeing_hide_stats_posts">Ukryj ilościowe statystyki na postach</string>
<string name="review_notifications">Przejrzyj powiadomienia</string>
<string name="account_note_saved">Zapisano!</string>
<string name="account_note_hint">Twoja prywatna notatka o tym koncie</string>
<string name="duration_indefinite">Nieskończona</string>
<string name="duration_indefinite">Nieograniczony</string>
<string name="post_media_audio">Dźwięk</string>
<string name="notification_subscription_description">Powiadomienia o opublikowaniu nowego wpisu przez kogoś, kogo obserwujesz</string>
<string name="pref_main_nav_position">Pozycja głównego paska nawigacji</string>
@ -552,9 +551,11 @@
<string name="notification_sign_up_format">%s zarejestrował(a) się</string>
<string name="notification_sign_up_name">Rejestracje</string>
<string name="notification_sign_up_description">Powiadomienia o nowych użytkownikach</string>
<string name="notification_update_description">Powiadomienia o edycji wpisów z którymi interaktowałeś/aś</string>
<string name="notification_update_description">Powiadomienia o edycji wpisów z którymi dokonałeś/aś interakcji</string>
<string name="pref_title_notification_filter_sign_ups">ktoś zarejestrował się</string>
<string name="pref_title_notification_filter_updates">wpis, z którym interaktowałem/am został edytowany</string>
<string name="pref_title_notification_filter_updates">wpis, z którym dokonałem/am interakcji został edytowany</string>
<string name="notification_update_format">%s edytował(a) swój wpis</string>
<string name="notification_update_name">Edycje wpisów</string>
<string name="saving_draft">Zapisywanie szkicu…</string>
<string name="error_could_not_load_login_page">Nie można załadować strony logowania.</string>
</resources>

View File

@ -246,7 +246,6 @@
<string name="add_account_description">Adicionar nova conta Mastodon</string>
<string name="action_lists">Listas</string>
<string name="title_lists">Listas</string>
<string name="title_list_timeline">Linha da lista</string>
<string name="compose_active_account_description">Usando a conta %1$s</string>
<string name="error_failed_set_caption">Erro ao incluir descrição</string>
<string name="action_set_caption">Descrever</string>

View File

@ -325,7 +325,7 @@
<string name="action_lists">Listas</string>
<string name="error_rename_list">Não foi possível renomear a lista</string>
<string name="title_lists">Listas</string>
<string name="title_list_timeline">Cronologia da timeline</string>
<string name="error_create_list">Não foi possível criar a lista</string>
<string name="error_delete_list">Não foi possível apagar a lista</string>
<string name="action_create_list">Criar uma lista</string>

View File

@ -296,7 +296,6 @@
<string name="add_account_description">Добавить новый акканут Mastodon</string>
<string name="action_lists">Списки</string>
<string name="title_lists">Списки</string>
<string name="title_list_timeline">Список лент</string>
<string name="error_create_list">Не удалось создать список</string>
<string name="error_rename_list">Не удалось переименовать список</string>
<string name="error_delete_list">Не удалось удалить список</string>

View File

@ -202,7 +202,6 @@
<string name="error_rename_list">पुनः सूचिनामकरणं कर्तुमशक्यम्</string>
<string name="error_create_list">सूचिनिर्माणं कर्तुमशक्यम्</string>
<string name="dialog_message_cancel_follow_request">अनुसरणानुरोधो नश्यताम् \?</string>
<string name="title_list_timeline">सूचेः समयतालिका</string>
<string name="title_lists">सूचयः</string>
<string name="action_lists">सूचयः</string>
<string name="add_account_description">नवमास्टोडोनलेखा युज्यताम्</string>

View File

@ -247,7 +247,6 @@
<string name="add_account_description">Dodaj nov Mastodon račun</string>
<string name="action_lists">Seznami</string>
<string name="title_lists">Seznami</string>
<string name="title_list_timeline">Seznam časovnice</string>
<string name="error_create_list">Seznama ni bilo mogoče ustvariti</string>
<string name="error_rename_list">Seznama ni bilo mogoče preimenovati</string>
<string name="error_delete_list">Seznama ni bilo mogoče izbrisati</string>

View File

@ -269,7 +269,6 @@
<string name="add_account_description">Lägg till ett nytt Mastodon-konto</string>
<string name="action_lists">Listor</string>
<string name="title_lists">Listor</string>
<string name="title_list_timeline">Lista tidslinje</string>
<string name="error_create_list">Kunde inte skapa lista</string>
<string name="error_rename_list">Kunde inte byta namn på lista</string>
<string name="error_delete_list">Kunde inte radera lista</string>

View File

@ -216,7 +216,6 @@
<string name="add_account_description">புதிய Mastodon கணக்கைச் சேர்க்க</string>
<string name="action_lists">பட்டியல்கள்</string>
<string name="title_lists">பட்டியல்கள்</string>
<string name="title_list_timeline">காலவரிசை பட்டியல்</string>
<string name="compose_active_account_description">%1$s கணக்குடன் பதிவிட</string>
<string name="error_failed_set_caption">தலைப்பை அமைக்க முடியவில்லை</string>
<string name="action_set_caption">தலைப்பை அமை</string>

View File

@ -144,7 +144,6 @@
<string name="error_delete_list">ไม่สามารถลบรายการได้</string>
<string name="error_rename_list">ไม่สามารถเปลี่ยนชื่อรายการได้</string>
<string name="error_create_list">ไม่สามารถสร้างรายการได้</string>
<string name="title_list_timeline">ไทม์ไลน์ในรายการ</string>
<string name="add_account_description">เพิ่มบัญชี Mastodon ใหม่</string>
<string name="add_account_name">เพิ่มบัญชี</string>
<string name="filter_add_description">วลีที่ต้องการกรอง</string>

View File

@ -243,7 +243,6 @@
<string name="add_account_description">Yeni Mastodon hesabı ekle</string>
<string name="action_lists">Listeler</string>
<string name="title_lists">Listeler</string>
<string name="title_list_timeline">Zaman çizelgesini listele</string>
<string name="compose_active_account_description">%1$s hesabıyla gönderiliyor</string>
<plurals name="hint_describe_for_visually_impaired">
<item quantity="other">Görsel engelli için tanımla

View File

@ -285,7 +285,6 @@
<string name="error_delete_list">Не вдалося видалити список</string>
<string name="error_rename_list">Не вдалося перейменувати список</string>
<string name="error_create_list">Не вдалося створити список</string>
<string name="title_list_timeline">Стрічка списку</string>
<string name="add_account_description">Додати новий обліковий запис Mastodon</string>
<string name="add_account_name">Додати обліковий запис</string>
<string name="filter_add_description">Фільтрувати фразу</string>
@ -550,4 +549,5 @@
<string name="notification_update_name">Редакції допису</string>
<string name="title_login">Вхід</string>
<string name="error_could_not_load_login_page">Не вдалося завантажити сторінку входу.</string>
<string name="saving_draft">Збереження чернетки…</string>
</resources>

View File

@ -448,7 +448,6 @@
<string name="action_delete_list">Xóa danh sách</string>
<string name="action_rename_list">Đổi tên danh sách</string>
<string name="action_create_list">Tạo danh sách</string>
<string name="title_list_timeline">Danh sách bảng tin</string>
<string name="add_account_description">Thêm tài khoản Mastodon</string>
<string name="add_account_name">Thêm tài khoản</string>
<string name="filter_add_description">Thêm mô tả</string>
@ -517,4 +516,5 @@
<string name="notification_update_description">Thông báo khi tút mà tôi tương tác bị sửa</string>
<string name="title_login">Đăng nhập</string>
<string name="error_could_not_load_login_page">Không thể tải trang đăng nhập.</string>
<string name="saving_draft">Đang lưu nháp…</string>
</resources>

View File

@ -277,7 +277,6 @@
<string name="add_account_description">添加新的 Mastodon 帐号</string>
<string name="action_lists">列表</string>
<string name="title_lists">列表</string>
<string name="title_list_timeline">列表时间轴</string>
<string name="error_create_list">无法新建列表</string>
<string name="error_rename_list">无法重命名列表</string>
<string name="error_delete_list">无法删除列表</string>
@ -536,4 +535,5 @@
<string name="notification_update_name">嘟文编辑</string>
<string name="notification_update_description">当你进行过互动的嘟文被编辑时发出通知</string>
<string name="error_could_not_load_login_page">无法加载登录页。</string>
<string name="saving_draft">正在保存草稿…</string>
</resources>

View File

@ -276,7 +276,6 @@
<string name="add_account_description">加入新的 Mastodon 帳號</string>
<string name="action_lists">列表</string>
<string name="title_lists">列表</string>
<string name="title_list_timeline">列表時間軸</string>
<string name="error_create_list">無法新建列表</string>
<string name="error_rename_list">無法重命名列表</string>
<string name="error_delete_list">無法刪除列表</string>

View File

@ -270,7 +270,6 @@
<string name="add_account_description">加入新的 Mastodon 帳號</string>
<string name="action_lists">列表</string>
<string name="title_lists">列表</string>
<string name="title_list_timeline">列表時間軸</string>
<string name="error_create_list">無法新建列表</string>
<string name="error_rename_list">無法重命名列表</string>
<string name="error_delete_list">無法刪除列表</string>

View File

@ -274,7 +274,6 @@
<string name="add_account_description">添加新的 Mastodon 帐号</string>
<string name="action_lists">列表</string>
<string name="title_lists">列表</string>
<string name="title_list_timeline">列表时间轴</string>
<string name="error_create_list">无法新建列表</string>
<string name="error_rename_list">无法重命名列表</string>
<string name="error_delete_list">无法删除列表</string>

View File

@ -276,7 +276,6 @@
<string name="add_account_description">加入新的 Mastodon 帳號</string>
<string name="action_lists">列表</string>
<string name="title_lists">列表</string>
<string name="title_list_timeline">列表時間軸</string>
<string name="error_create_list">無法新建列表</string>
<string name="error_rename_list">無法重命名列表</string>
<string name="error_delete_list">無法刪除列表</string>
@ -525,4 +524,6 @@
<string name="pref_title_alway_open_spoiler">總是顯示被標注為內容警告的嘟文</string>
<string name="failed_search">搜尋失敗</string>
<string name="title_accounts">帳號</string>
<string name="title_login">登入</string>
<string name="error_could_not_load_login_page">無法載入登入頁面。</string>
</resources>

View File

@ -44,6 +44,7 @@
<string name="title_mutes">Muted users</string>
<string name="title_blocks">Blocked users</string>
<string name="title_domain_mutes">Hidden domains</string>
<string name="title_migration_relogin">Re-login for push notifications</string>
<string name="title_follow_requests">Follow Requests</string>
<string name="title_edit_profile">Edit your profile</string>
<string name="title_drafts">Drafts</string>
@ -152,6 +153,8 @@
<string name="action_open_reblogger">Open boost author</string>
<string name="action_open_reblogged_by">Show boosts</string>
<string name="action_open_faved_by">Show favorites</string>
<string name="action_dismiss">Dismiss</string>
<string name="action_details">Details</string>
<string name="action_quote">Quote</string>
<string name="action_authorize">Authorize Now!</string>
<string name="action_tab_jump_to_top">Jump to top</string>
@ -413,7 +416,6 @@
<string name="action_lists">Lists</string>
<string name="title_lists">Lists</string>
<string name="title_list_timeline">List timeline</string>
<string name="error_create_list">Could not create list</string>
<string name="error_rename_list">Could not rename list</string>
<string name="error_delete_list">Could not delete list</string>
@ -672,6 +674,14 @@
<string name="action_unsubscribe_account">Unsubscribe</string>
<string name="tusky_compose_post_quicksetting_label">Compose Post</string>
<string name="account_date_joined">Joined %1$s</string>
<string name="saving_draft">Saving draft…</string>
<string name="tips_push_notification_migration">Re-login all accounts to enable push notification support.</string>
<string name="dialog_push_notification_migration">In order to use push notifications via UnifiedPush, Tusky needs permission to subscribe to notifications on your Mastodon server. This requires a re-login to change the OAuth scopes granted to Tusky. Using the re-login option here or in Account Preferences will preserve all of your local drafts and cache.</string>
<string name="dialog_push_notification_migration_other_accounts">You have re-logged into your current account to grant push subscription permission to Tusky. However, you still have other accounts that have not been migrated this way. Switch to them and re-login one by one in order to enable UnifiedPush notifications support.</string>
</resources>

View File

@ -47,6 +47,8 @@ import org.robolectric.Robolectric
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.fakes.RoboMenuItem
import java.util.Date
import kotlin.collections.HashMap
/**
* Created by charlag on 3/7/18.
@ -466,22 +468,23 @@ class ComposeActivityTest {
null,
listOf("en"),
Account(
"1",
"admin",
"admin",
"admin",
"",
"https://example.token",
"",
"",
false,
0,
0,
0,
null,
false,
emptyList(),
emptyList()
id = "1",
localUsername = "admin",
username = "admin",
displayName = "admin",
createdAt = Date(),
note = "",
url = "https://example.token",
avatar = "",
header = "",
locked = false,
statusesCount = 0,
followersCount = 0,
followingCount = 0,
source = null,
bot = false,
emojis = emptyList(),
fields = emptyList(),
),
maximumLegacyTootCharacters,
null,

View File

@ -15,7 +15,7 @@
package com.keylesspalace.tusky
import com.keylesspalace.tusky.util.ComposeTokenizer
import com.keylesspalace.tusky.components.compose.ComposeTokenizer
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith

View File

@ -5,8 +5,8 @@ buildscript {
gradlePluginPortal()
}
dependencies {
classpath "com.android.tools.build:gradle:7.1.2"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.20"
classpath "com.android.tools.build:gradle:7.2.0"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21"
classpath "org.jlleitschuh.gradle:ktlint-gradle:10.2.1"
}
}

View File

@ -0,0 +1,6 @@
Tusky v18.0
- Підтримка нових типів сповіщень Mastodon 3.5
- Кращий вигляд позначки бота і розширений вибір тем
- Текст тепер можна вибрати у докладному поданні допису
- Виправлено безліч помилок, включно з тою, яка перешкоджала входу на Android 6 і старіших

View File

@ -0,0 +1,6 @@
Tusky v18.0
- Hỗ trợ những kiểu thông báo mới của Mastodon 3.5
- Nhãn của tài khoản nhìn đẹp hơn và thay đổi theo chủ đề
- Cho phép chọn và sao chép nội dung tút
- Sửa lỗi chặn đăng nhập trên Android 6 trở xuống

View File

@ -0,0 +1,3 @@
Tusky v15.1
此版本修复了给图片添加标题时会崩溃的问题

View File

@ -0,0 +1,8 @@
Tusky v16.0
- 时间线加载逻辑完全重写,提升了流畅度、稳定性,更便于维护。
- APNG和动画WebP格式的动态自定义表情符号。
- 修正大量BUG
- 支持Android 11
- 新增界面语言支持:苏格兰盖尔语、加利西亚语、乌克兰语
- 改进翻译