Merge branch 'develop' into cleanup_unused_resources

# Conflicts:
#	app/lint-baseline.xml
This commit is contained in:
Conny Duck 2023-07-11 17:26:35 +02:00
commit 9a93a64033
260 changed files with 7832 additions and 2780 deletions

39
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,39 @@
name: CI
on:
push:
tags:
- '*'
pull_request:
workflow_dispatch:
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
- name: Gradle Wrapper Validation
uses: gradle/wrapper-validation-action@v1
- name: Gradle Build Action
uses: gradle/gradle-build-action@v2
- name: ktlint
run: ./gradlew clean ktlintCheck
- name: Regular lint
run: ./gradlew lintGreenDebug
- name: Test
run: ./gradlew app:testGreenDebugUnitTest
- name: Build
run: ./gradlew app:greenDebug

View File

@ -6,6 +6,153 @@
### Significant bug fixes
## v23.0
### New features and other improvements
- **New preference to scale UI text**, [PR#3248](https://github.com/tuskyapp/Tusky/pull/3248) by [@nikclayton](https://mastodon.social/@nikclayton)
### Significant bug fixes
- **Save account information correctly**, [PR#3720](https://github.com/tuskyapp/Tusky/pull/3720) by [@connyduck](https://chaos.social/@ConnyDuck)
- If you were logged in with multiple accounts it was possible to switch accounts in a way that the UI showed the new account, but database operations were happening using the old account.
- **"pull" notifications on devices running Android versions <= 11**, [PR#3649](https://github.com/tuskyapp/Tusky/pull/3649) by [@nikclayton](https://mastodon.social/@nikclayton)
- Pull notifications (i.e., not using ntfy.sh) could silently fail on devices running Android 11 and below
- **Work around Android bug where text fields could "forget" they can copy/paste**, [PR#3707](https://github.com/tuskyapp/Tusky/pull/3707) by [@nikclayton](https://mastodon.social/@nikclayton)
- **Viewing "diffs" in edit history will not extend off screen edge**, [PR#3431](https://github.com/tuskyapp/Tusky/pull/3431) by [@nikclayton](https://mastodon.social/@nikclayton)
- **Don't crash if your server has no post edit history**, [PR#3747](https://github.com/tuskyapp/Tusky/pull/3747) by [@nikclayton](https://mastodon.social/@nikclayton)
- Your Mastodon server might know that a post has been edited, but not know the details of those edits. Trying to view the history of those statuses no longer crashes.
- **Add a "Delete" button when editing a filter**, [PR#3553](https://github.com/tuskyapp/Tusky/pull/3553) by [@Tak](https://mastodon.gamedev.place/@Tak)
- **Show non-square emoji correctly**, [PR#3711](https://github.com/tuskyapp/Tusky/pull/3711) by [@connyduck](https://chaos.social/@ConnyDuck)
- **Potential crash when editing profile fields**, [PR#3808](https://github.com/tuskyapp/Tusky/pull/3808) by [@nikclayton](https://mastodon.social/@nikclayton)
- **Oversized context menu when editing image descriptions**, [PR#3787](https://github.com/tuskyapp/Tusky/pull/3787) by [@connyduck](https://chaos.social/@ConnyDuck)
## v23.0 beta 2
### Significant bug fixes
- **Potential crash when editing profile fields**, [PR#3808](https://github.com/tuskyapp/Tusky/pull/3808) by [@nikclayton](https://mastodon.social/@nikclayton)
- **Oversized context menu when editing image descriptions**, [PR#3787](https://github.com/tuskyapp/Tusky/pull/3787) by [@connyduck](https://chaos.social/@ConnyDuck)
## v23.0 beta 1
### New features and other improvements
- **New preference to scale UI text**, [PR#3248](https://github.com/tuskyapp/Tusky/pull/3248) by [@nikclayton](https://mastodon.social/@nikclayton)
### Significant bug fixes
- **Save account information correctly**, [PR#3720](https://github.com/tuskyapp/Tusky/pull/3720) by [@connyduck](https://chaos.social/@ConnyDuck)
- If you were logged in with multiple accounts it was possible to switch accounts in a way that the UI showed the new account, but database operations were happening using the old account.
- **"pull" notifications on devices running Android versions <= 11**, [PR#3649](https://github.com/tuskyapp/Tusky/pull/3649) by [@nikclayton](https://mastodon.social/@nikclayton)
- Pull notifications (i.e., not using ntfy.sh) could silently fail on devices running Android 11 and below
- **Work around Android bug where text fields could "forget" they can copy/paste**, [PR#3707](https://github.com/tuskyapp/Tusky/pull/3707) by [@nikclayton](https://mastodon.social/@nikclayton)
- **Viewing "diffs" in edit history will not extend off screen edge**, [PR#3431](https://github.com/tuskyapp/Tusky/pull/3431) by [@nikclayton](https://mastodon.social/@nikclayton)
- **Don't crash if your server has no post edit history**, [PR#3747](https://github.com/tuskyapp/Tusky/pull/3747) by [@nikclayton](https://mastodon.social/@nikclayton)
- Your Mastodon server might know that a post has been edited, but not know the details of those edits. Trying to view the history of those statuses no longer crashes.
- **Add a "Delete" button when editing a filter**, [PR#3553](https://github.com/tuskyapp/Tusky/pull/3553) by [@Tak](https://mastodon.gamedev.place/@Tak)
- **Show non-square emoji correctly**, [PR#3711](https://github.com/tuskyapp/Tusky/pull/3711) by [@connyduck](https://chaos.social/@ConnyDuck)
## v22.0
### New features and other improvements
- **View trending hashtags**, [PR#3149](https://github.com/tuskyapp/Tusky/pull/3149) by [@knossos](https://fosstodon.org/@knossos)
- View trending hashtags from the side menu, or by adding them to a new tab.
- **Edit image description and focus point**, [PR#3215](https://github.com/tuskyapp/Tusky/pull/3215) by [@Tak](https://mastodon.gamedev.place/@Tak)
- Edit image descriptions and focus points when editing posts.
- **View profile banner images**, [PR#3274](https://github.com/tuskyapp/Tusky/pull/3274) by [@Tak](https://mastodon.gamedev.place/@Tak)
- Tap the banner image on any profile to view it full size, save, share, etc.
- **Follow new hashtags**, [PR#3275](https://github.com/tuskyapp/Tusky/pull/3275) by [@nikclayton](https://mastodon.social/@nikclayton)
- Follow new hashtags from the "Followed hashtags" screen.
- **Better ordering when selecting languages**, [PR#3293](https://github.com/tuskyapp/Tusky/pull/3293) by [@Tak](https://mastodon.gamedev.place/@Tak)
- Tusky will prioritise the language of the post being replied to, your default posting language, configured Tusky languages, and configured system languages when ordering the list of languages to post in.
- **"Load more" break is more prominent**, [PR#3376](https://github.com/tuskyapp/Tusky/pull/3376) by [@lakoja](https://freiburg.social/@lakoja)
- Adjusted the design so the "Load more" break in a timeline is more obvious.
- **Add "Refresh" menu**, [PR#3121](https://github.com/tuskyapp/Tusky/pull/3121) by [@nikclayton](https://mastodon.social/@nikclayton)
- Tusky timelines can now be refreshed from a menu as well as swiping, making this accessible to assistive devices.
- **Notifications timeline improvements**, [PR#3159](https://github.com/tuskyapp/Tusky/pull/3159) by [@nikclayton](https://mastodon.social/@nikclayton)
- Notifications no longer need to "Load more", they are loaded automatically as you scroll.
- Errors when interacting with notifications are displayed to the user, with a "Retry" option.
- **Show the difference between versions of a post**, [PR#3314](https://github.com/tuskyapp/Tusky/pull/3314) by [@nikclayton](https://mastodon.social/@nikclayton)
- Viewing the edits to a post highlights the differences (text that was added or deleted) between the different versions.
- **Support Mastodon v4 filters**, [PR#3188](https://github.com/tuskyapp/Tusky/pull/3188) by [@Tak](https://mastodon.gamedev.place/@Tak)
- Mastodon v4 introduced additional [filtering controls](https://docs.joinmastodon.org/user/moderating/#filters).
- **Option to show post statistics in the timeline**, [PR#3413](https://github.com/tuskyapp/Tusky/pull/3413)
- Tusky can now (optionally) show the number of replies, reposts, and favourites a post has received, in the timeline.
- **Expanded tappable area for links, hashtags, and mentions in a post**, [PR#3382](https://github.com/tuskyapp/Tusky/pull/3382) by [@nikclayton](https://mastodon.social/@nikclayton)
- Links, hashtags, and mentions in a post now react to taps that are a little above, below, or to the side of the tappable text, making them more accessible.
### Significant bug fixes
- **Remember selected tab and position**, [PR#3255](https://github.com/tuskyapp/Tusky/pull/3255) by [@nikclayton](https://mastodon.social/@nikclayton)
- Changing your tab settings (adding, removing, re-ordering) remembers your reading position in those tabs.
- **Show player controls during audio playback**, [PR#3286](https://github.com/tuskyapp/Tusky/pull/3286) by [@EricFrohnhoefer](https://mastodon.social/@EricFrohnhoefer)
- A regression from v21.0 where the media player controls could not be used.
- **Keep notifications until read**, [PR#3312](https://github.com/tuskyapp/Tusky/pull/3312) by [@lakoja](https://freiburg.social/@lakoja)
- Opening Tusky would dismiss all active Tusky Android notifications.
- **Fix copying URLs at the end of a post**, [PR#3380](https://github.com/tuskyapp/Tusky/pull/3380) by [@nikclayton](https://mastodon.social/@nikclayton)
- Copying a URL from the end of a post could include an extra Unicode whitespace character, making the URL unusable as is.
- **Correctly display mixed RTL and LTR text in profiles**, [PR#3328](https://github.com/tuskyapp/Tusky/pull/3328) by [@nikclayton](https://mastodon.social/@nikclayton)
- Profile text that contained a mix of right-to-left and left-to-right writing directions would display incorrectly.
- **Stop showing duplicates of edited posts in threads**, [PR#3377](https://github.com/tuskyapp/Tusky/pull/3377) by [@Tak](https://mastodon.gamedev.place/@Tak)
- Editing a post in thread view would show the old and new version of the post in the thread.
- **Correct post length calculation**, [PR#3392](https://github.com/tuskyapp/Tusky/pull/3392) by [@nikclayton](https://mastodon.social/@nikclayton)
- In a post that mentioned a user (e.g., `@tusky@mastodon.social`) Tusky was incorrectly including the `@mastodon.social` part when calculating the post's length, leading to incorrect "This post is too long" errors.
- **Always publish image captions**, [PR#3421](https://github.com/tuskyapp/Tusky/pull/3421) by [@lakoja](https://freiburg.social/@lakoja)
- Finishing editing an image caption before the image had finished loading would lose the caption.
- **Clicking "Compose" from a notification would set the wrong account**, [PR#3688](https://github.com/tuskyapp/Tusky/pull/3688)
## v22.0 beta 7
### Significant bug fixes
- **Fetch all outstanding Mastodon notifications when creating Android notifications**, [PR#3700](https://github.com/tuskyapp/Tusky/pull/3700)
- **Clicking "Compose" from a notification would set the wrong account**, [PR#3688](https://github.com/tuskyapp/Tusky/pull/3688)
- **Ensure "last read notification ID" is saved to the correct account**, [PR#3697](https://github.com/tuskyapp/Tusky/pull/3697)
## v22.0 beta 6
### Significant bug fixes
- **Save reading position in the Notifications tab more frequently**, [PR#3685](https://github.com/tuskyapp/Tusky/pull/3685)
## v22.0 beta 5
## Significant bug fixes
- **Rolled back APNG library to fix broken animated emojis**, [PR#3676](https://github.com/tuskyapp/Tusky/pull/3676)
- **Save local copy of notification marker in case server does not support the API**, [PR#3672](https://github.com/tuskyapp/Tusky/pull/3672)
## v22.0 beta 4
### Significant bug fixes
- **Fixed repeated fetch of notifications if configured with multiple accounts**, [PR#3660](https://github.com/tuskyapp/Tusky/pull/3660)
## v22.0 beta 3
### Significant bug fixes
- **Fixed crash when viewing a thread**, [PR#3622](https://github.com/tuskyapp/Tusky/pull/3622)
- **Fixed crash processing Mastodon filters**, [PR#3634](https://github.com/tuskyapp/Tusky/pull/3634)
- **Links in bios of follow/follow request notifications are clickable**, [PR#3646](https://github.com/tuskyapp/Tusky/pull/3646)
- **Android Notifications updates**, [PR#3636](https://github.com/tuskyapp/Tusky/pull/3626)
- Android notification for a Mastodon notification should only be shown once
- Android notifications are grouped by Mastodon notification type (follow, mention, boost, etc)
- Potential for missing notifications has been removed
## v22.0 beta 2
### Significant bug fixes
- **Improved notification loading speed**, [PR#3598](https://github.com/tuskyapp/Tusky/pull/3598)
- **Restore showing 0/1/1+ for replies**, [PR#3590](https://github.com/tuskyapp/Tusky/pull/3590)
- **Show filter titles, not filter keywords, on filtered posts**, [PR#3589](https://github.com/tuskyapp/Tusky/pull/3589)
- **Fixed a bug where opening a status could open an unrelated link**, [PR#3600](https://github.com/tuskyapp/Tusky/pull/3600)
- **Show "Add" button in correct place when there are no filters**, [PR#3561](https://github.com/tuskyapp/Tusky/pull/3561)
- **Fixed assorted crashes**
## v22.0 beta 1
### New features and other improvements

View File

@ -31,11 +31,5 @@ If you have any bug reports, feature requests or questions please open an issue
### Contributing
We always welcome new contributors! Please read our [contribution guide](https://github.com/tuskyapp/Tusky/blob/develop/CONTRIBUTING.md) to get started.
### Head of development
This app was developed by [Vavassor@mastodon.social](https://mastodon.social/@Vavassor).
The current maintainer is [ConnyDuck@chaos.social](https://chaos.social/@ConnyDuck).
### Development chatroom
https://riot.im/app/#/room/#Tusky:matrix.org

View File

@ -1,5 +1,6 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.google.ksp)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.kotlin.parcelize)
@ -28,8 +29,8 @@ android {
namespace "com.keylesspalace.tusky"
minSdk 23
targetSdk 33
versionCode 103
versionName "22.0 beta 1"
versionCode 113
versionName "23.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@ -113,11 +114,9 @@ android {
}
}
kapt {
arguments {
arg("room.schemaLocation", "$projectDir/schemas")
arg("room.incremental", "true")
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
arg("room.incremental", "true")
}
configurations {
@ -134,7 +133,7 @@ dependencies {
implementation libs.bundles.androidx
implementation libs.bundles.room
kapt libs.androidx.room.compiler
ksp libs.androidx.room.compiler
implementation libs.android.material
@ -148,7 +147,7 @@ dependencies {
implementation libs.conscrypt.android
implementation libs.bundles.glide
kapt libs.glide.compiler
ksp libs.glide.compiler
implementation libs.bundles.rxjava3

File diff suppressed because it is too large Load Diff

View File

@ -30,9 +30,16 @@
Disable these for the time being. -->
<issue id="UnusedIds" severity="ignore" />
<!-- Logs are stripped in release builds. -->
<issue id="LogConditional" severity="ignore" />
<!-- Ensure we are warned about errors in the baseline -->
<issue id="LintBaseline" severity="warning" />
<!-- Warn about typos. The typo database in lint is not exhaustive, and it's unclear
how to add to it when it's wrong. -->
<issue id="Typos" severity="warning" />
<!-- Mark all other lint issues as errors -->
<issue id="all" severity="error" />
</lint>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,995 @@
{
"formatVersion": 1,
"database": {
"version": 50,
"identityHash": "4eaf69e915d4a15f021547b725101acd",
"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, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)",
"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
},
{
"fieldPath": "failedToSendNew",
"columnName": "failedToSendNew",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scheduledAt",
"columnName": "scheduledAt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "statusId",
"columnName": "statusId",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"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, `clientId` TEXT, `clientSecret` TEXT, `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, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` 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, `lastVisibleHomeTimelineStatusId` TEXT)",
"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": "clientId",
"columnName": "clientId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "clientSecret",
"columnName": "clientSecret",
"affinity": "TEXT",
"notNull": false
},
{
"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": "notificationsReports",
"columnName": "notificationsReports",
"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": "defaultPostLanguage",
"columnName": "defaultPostLanguage",
"affinity": "TEXT",
"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": "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
},
{
"fieldPath": "lastVisibleHomeTimelineStatusId",
"columnName": "lastVisibleHomeTimelineStatusId",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"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, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, 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
},
{
"fieldPath": "videoSizeLimit",
"columnName": "videoSizeLimit",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "imageSizeLimit",
"columnName": "imageSizeLimit",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "imageMatrixLimit",
"columnName": "imageMatrixLimit",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxMediaAttachments",
"columnName": "maxMediaAttachments",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxFields",
"columnName": "maxFields",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxFieldNameLength",
"columnName": "maxFieldNameLength",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxFieldValueLength",
"columnName": "maxFieldValueLength",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"instance"
]
},
"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, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` 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, `language` TEXT, `filtered` 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": "editedAt",
"columnName": "editedAt",
"affinity": "INTEGER",
"notNull": false
},
{
"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": "repliesCount",
"columnName": "repliesCount",
"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": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "filtered",
"columnName": "filtered",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"serverId",
"timelineUserId"
]
},
"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": {
"autoGenerate": false,
"columnNames": [
"serverId",
"timelineUserId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ConversationEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER 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_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` 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, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"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.editedAt",
"columnName": "s_editedAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "lastStatus.emojis",
"columnName": "s_emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.favouritesCount",
"columnName": "s_favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.repliesCount",
"columnName": "s_repliesCount",
"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
},
{
"fieldPath": "lastStatus.language",
"columnName": "s_language",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"accountId"
]
},
"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, '4eaf69e915d4a15f021547b725101acd')"
]
}
}

File diff suppressed because it is too large Load Diff

View File

@ -155,8 +155,6 @@
<activity android:name="com.keylesspalace.tusky.components.filters.EditFilterActivity"
android:windowSoftInputMode="adjustResize" />
<receiver android:name=".receiver.NotificationClearBroadcastReceiver"
android:exported="false" />
<receiver
android:name=".receiver.SendStatusBroadcastReceiver"
android:enabled="true"

View File

@ -46,7 +46,6 @@ import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
import com.keylesspalace.tusky.viewmodel.State
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
private typealias AccountInfo = Pair<TimelineAccount, Boolean>
@ -146,23 +145,10 @@ class AccountsInListFragment : DialogFragment(), Injectable {
private fun handleError(error: Throwable) {
binding.messageView.show()
val retryAction = { _: View ->
binding.messageView.setup(error) { _: View ->
binding.messageView.hide()
viewModel.load(listId)
}
if (error is IOException) {
binding.messageView.setup(
R.drawable.elephant_offline,
R.string.error_network,
retryAction
)
} else {
binding.messageView.setup(
R.drawable.elephant_error,
R.string.error_generic,
retryAction
)
}
}
private fun onRemoveFromList(accountId: String) {

View File

@ -16,9 +16,11 @@
package com.keylesspalace.tusky;
import android.app.ActivityManager;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Color;
@ -45,6 +47,7 @@ import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.interfaces.AccountSelectionListener;
import com.keylesspalace.tusky.interfaces.PermissionRequester;
import com.keylesspalace.tusky.settings.PrefKeys;
import com.keylesspalace.tusky.util.ThemeUtils;
import java.util.ArrayList;
@ -54,6 +57,7 @@ import java.util.List;
import javax.inject.Inject;
public abstract class BaseActivity extends AppCompatActivity implements Injectable {
private static final String TAG = "BaseActivity";
@Inject
public AccountManager accountManager;
@ -93,6 +97,44 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
requesters = new HashMap<>();
}
@Override
protected void attachBaseContext(Context newBase) {
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(newBase);
// Scale text in the UI from PrefKeys.UI_TEXT_SCALE_RATIO
float uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100F);
Configuration configuration = newBase.getResources().getConfiguration();
// Adjust `fontScale` in the configuration.
//
// You can't repeatedly adjust the `fontScale` in `newBase` because that will contain the
// result of previous adjustments. E.g., going from 100% to 80% to 100% does not return
// you to the original 100%, it leaves it at 80%.
//
// Instead, calculate the new scale from the application context. This is unaffected by
// changes to the base context. It does contain contain any changes to the font scale from
// "Settings > Display > Font size" in the device settings, so scaling performed here
// is in addition to any scaling in the device settings.
Configuration appConfiguration = newBase.getApplicationContext().getResources().getConfiguration();
// This only adjusts the fonts, anything measured in `dp` is unaffected by this.
// You can try to adjust `densityDpi` as shown in the commented out code below. This
// works, to a point. However, dialogs do not react well to this. Beyond a certain
// scale (~ 120%) the right hand edge of the dialog will clip off the right of the
// screen.
//
// So for now, just adjust the font scale
//
// val displayMetrics = appContext.resources.displayMetrics
// configuration.densityDpi = ((displayMetrics.densityDpi * uiScaleRatio).toInt())
configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100F;
Context fontScaleContext = newBase.createConfigurationContext(configuration);
super.attachBaseContext(fontScaleContext);
}
protected boolean requiresLogin() {
return true;
}
@ -213,7 +255,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
}
public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) {
accountManager.setActiveAccount(account);
accountManager.setActiveAccount(account.getId());
Intent intent = new Intent(this, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
intent.putExtra(MainActivity.REDIRECT_URL, url);

View File

@ -737,7 +737,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
onTabSelectedListener = object : OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
binding.mainToolbar.title = tabs[tab.position].title(this@MainActivity)
binding.mainToolbar.title = tab.contentDescription
refreshComposeButtonState(tabAdapter, tab.position)
}

View File

@ -21,6 +21,8 @@ import android.os.Bundle
import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ProgressBar
@ -42,12 +44,12 @@ import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.MaterialArcMotion
import com.google.android.material.transition.MaterialContainerTransform
import com.keylesspalace.tusky.adapter.ItemInteractionListener
import com.keylesspalace.tusky.adapter.ListSelectionAdapter
import com.keylesspalace.tusky.adapter.TabAdapter
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.getDimension
import com.keylesspalace.tusky.util.hide
@ -272,7 +274,13 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
}
private fun showSelectListDialog() {
val adapter = ListSelectionAdapter(this)
val adapter = object : ArrayAdapter<MastoList>(this, android.R.layout.simple_list_item_1) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = super.getView(position, convertView, parent)
getItem(position)?.let { item -> (view as TextView).text = item.title }
return view
}
}
val statusLayout = LinearLayout(this)
statusLayout.gravity = Gravity.CENTER
@ -298,12 +306,13 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
.setNegativeButton(android.R.string.cancel, null)
.setView(statusLayout)
.setAdapter(adapter) { _, position ->
val list = adapter.getItem(position)
val newTab = createTabDataFromId(LIST, listOf(list!!.id, list.title))
currentTabs.add(newTab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
updateAvailableTabs()
saveTabs()
adapter.getItem(position)?.let { item ->
val newTab = createTabDataFromId(LIST, listOf(item.id, item.title))
currentTabs.add(newTab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
updateAvailableTabs()
saveTabs()
}
}
val showProgressBarJob = getProgressBarJob(progress, 500)

View File

@ -18,15 +18,20 @@ package com.keylesspalace.tusky
import android.app.Application
import android.content.SharedPreferences
import android.util.Log
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import autodispose2.AutoDisposePlugins
import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.di.AppInjector
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.settings.SCHEMA_VERSION
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
import com.keylesspalace.tusky.util.LocaleManager
import com.keylesspalace.tusky.util.setAppNightMode
import com.keylesspalace.tusky.worker.PruneCacheWorker
import com.keylesspalace.tusky.worker.WorkerFactory
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import de.c1710.filemojicompat_defaults.DefaultEmojiPackList
@ -35,6 +40,7 @@ import de.c1710.filemojicompat_ui.helpers.EmojiPreference
import io.reactivex.rxjava3.plugins.RxJavaPlugins
import org.conscrypt.Conscrypt
import java.security.Security
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class TuskyApplication : Application(), HasAndroidInjector {
@ -42,7 +48,7 @@ class TuskyApplication : Application(), HasAndroidInjector {
lateinit var androidInjector: DispatchingAndroidInjector<Any>
@Inject
lateinit var notificationWorkerFactory: NotificationWorkerFactory
lateinit var workerFactory: WorkerFactory
@Inject
lateinit var localeManager: LocaleManager
@ -90,12 +96,24 @@ class TuskyApplication : Application(), HasAndroidInjector {
Log.w("RxJava", "undeliverable exception", it)
}
NotificationHelper.createWorkerNotificationChannel(this)
WorkManager.initialize(
this,
androidx.work.Configuration.Builder()
.setWorkerFactory(notificationWorkerFactory)
.setWorkerFactory(workerFactory)
.build()
)
// Prune the database every ~ 12 hours when the device is idle.
val pruneCacheWorker = PeriodicWorkRequestBuilder<PruneCacheWorker>(12, TimeUnit.HOURS)
.setConstraints(Constraints.Builder().setRequiresDeviceIdle(true).build())
.build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
PruneCacheWorker.PERIODIC_WORK_TAG,
ExistingPeriodicWorkPolicy.KEEP,
pruneCacheWorker
)
}
override fun androidInjector() = androidInjector

View File

@ -39,6 +39,7 @@ import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider
import androidx.core.content.IntentCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.viewpager2.adapter.FragmentStateAdapter
@ -96,7 +97,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
supportPostponeEnterTransition()
// Gather the parameters.
attachments = intent.getParcelableArrayListExtra(EXTRA_ATTACHMENTS)
attachments = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_ATTACHMENTS, AttachmentViewData::class.java)
val initialPosition = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0)
// Adapter is actually of existential type PageAdapter & SharedElementsTransitionListener

View File

@ -22,6 +22,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.databinding.ItemEditFieldBinding
import com.keylesspalace.tusky.entity.StringField
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.fixTextSelection
class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditFieldBinding>>() {
@ -81,12 +82,16 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
}
holder.binding.accountFieldNameText.doAfterTextChanged { newText ->
fieldData[holder.bindingAdapterPosition].first = newText.toString()
fieldData.getOrNull(holder.bindingAdapterPosition)?.first = newText.toString()
}
holder.binding.accountFieldValueText.doAfterTextChanged { newText ->
fieldData[holder.bindingAdapterPosition].second = newText.toString()
fieldData.getOrNull(holder.bindingAdapterPosition)?.second = newText.toString()
}
// Ensure the textview contents are selectable
holder.binding.accountFieldNameText.fixTextSelection()
holder.binding.accountFieldValueText.fixTextSelection()
}
class MutableStringPair(var first: String, var second: String)

View File

@ -25,9 +25,14 @@ import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapt
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewdata.NotificationViewData
@ -35,6 +40,7 @@ import com.keylesspalace.tusky.viewdata.NotificationViewData
class FollowRequestViewHolder(
private val binding: ItemFollowRequestBinding,
private val accountActionListener: AccountActionListener,
private val linkListener: LinkListener,
private val showHeader: Boolean
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
@ -87,9 +93,16 @@ class FollowRequestViewHolder(
binding.notificationTextView.visible(showHeader)
val formattedUsername = itemView.context.getString(R.string.post_username_format, account.username)
binding.usernameTextView.text = formattedUsername
val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(
R.dimen.avatar_radius_48dp
)
if (account.note.isEmpty()) {
binding.accountNote.hide()
} else {
binding.accountNote.show()
val emojifiedNote = account.note.parseAsMastodonHtml()
.emojify(account.emojis, binding.accountNote, animateEmojis)
setClickableText(binding.accountNote, emojifiedNote, emptyList(), null, linkListener)
}
val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(account.avatar, binding.avatar, avatarRadius, animateAvatar)
binding.avatarBadge.visible(showBotOverlay && account.bot)
}

View File

@ -1,41 +0,0 @@
/* Copyright 2019 kyori19
*
* 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.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemPickerListBinding
import com.keylesspalace.tusky.entity.MastoList
class ListSelectionAdapter(context: Context) : ArrayAdapter<MastoList>(context, R.layout.item_picker_list) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val binding = if (convertView == null) {
ItemPickerListBinding.inflate(LayoutInflater.from(context), parent, false)
} else {
ItemPickerListBinding.bind(convertView)
}
getItem(position)?.let { list ->
binding.root.text = list.title
}
return binding.root
}
}

View File

@ -397,7 +397,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (replyCountLabel == null) return;
if (fullStats) {
replyCountLabel.setText(NumberUtils.shortNumber(repliesCount));
replyCountLabel.setText(NumberUtils.formatNumber(repliesCount, 1000));
return;
}

View File

@ -114,11 +114,11 @@ public class StatusViewHolder extends StatusBaseViewHolder {
}
protected void setReblogsCount(int reblogsCount) {
reblogsCountLabel.setText(NumberUtils.shortNumber(reblogsCount));
reblogsCountLabel.setText(NumberUtils.formatNumber(reblogsCount, 1000));
}
protected void setFavouritedCount(int favouritedCount) {
favouritedCountLabel.setText(NumberUtils.shortNumber(favouritedCount));
favouritedCountLabel.setText(NumberUtils.formatNumber(favouritedCount, 1000));
}
protected void hideStatusInfo() {

View File

@ -1,94 +0,0 @@
/* Copyright 2023 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.adapter
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding
import com.keylesspalace.tusky.entity.TrendingTagHistory
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.viewdata.TrendingViewData
import java.text.NumberFormat
import kotlin.math.ln
import kotlin.math.pow
class TrendingTagViewHolder(
private val binding: ItemTrendingCellBinding
) : RecyclerView.ViewHolder(binding.root) {
fun setup(
tagViewData: TrendingViewData.Tag,
maxTrendingValue: Long,
trendingListener: LinkListener
) {
val reversedHistory = tagViewData.tag.history.reversed()
setGraph(reversedHistory, maxTrendingValue)
setTag(tagViewData.tag.name)
val totalUsage = tagViewData.tag.history.sumOf { it.uses.toLongOrNull() ?: 0 }
binding.totalUsage.text = formatNumber(totalUsage)
val totalAccounts = tagViewData.tag.history.sumOf { it.accounts.toLongOrNull() ?: 0 }
binding.totalAccounts.text = formatNumber(totalAccounts)
binding.currentUsage.text = reversedHistory.last().uses
binding.currentAccounts.text = reversedHistory.last().accounts
itemView.setOnClickListener {
trendingListener.onViewTag(tagViewData.tag.name)
}
setAccessibility(totalAccounts, tagViewData.tag.name)
}
private fun setGraph(history: List<TrendingTagHistory>, maxTrendingValue: Long) {
binding.graph.maxTrendingValue = maxTrendingValue
binding.graph.primaryLineData = history
.mapNotNull { it.uses.toLongOrNull() }
binding.graph.secondaryLineData = history
.mapNotNull { it.accounts.toLongOrNull() }
}
private fun setTag(tag: String) {
binding.tag.text = binding.root.context.getString(R.string.title_tag, tag)
}
private fun setAccessibility(totalAccounts: Long, tag: String) {
itemView.contentDescription =
itemView.context.getString(R.string.accessibility_talking_about_tag, totalAccounts, tag)
}
companion object {
private val numberFormatter: NumberFormat = NumberFormat.getInstance()
private val ln_1k = ln(1000.0)
/**
* Format numbers according to the current locale. Numbers < min have
* separators (',', '.', etc) inserted according to the locale.
*
* Numbers > min are scaled down to that by multiples of 1,000, and
* a suffix appropriate to the scaling is appended.
*/
private fun formatNumber(num: Long, min: Int = 100000): String {
if (num < min) return numberFormatter.format(num)
val exp = (ln(num.toDouble()) / ln_1k).toInt()
// TODO: is the choice of suffixes here locale-agnostic?
return String.format("%.1f %c", num / 1000.0.pow(exp.toDouble()), "KMGTPE"[exp - 1])
}
}
}

View File

@ -40,7 +40,6 @@ import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class ListsForAccountFragment : DialogFragment(), Injectable {
@ -103,16 +102,7 @@ class ListsForAccountFragment : DialogFragment(), Injectable {
binding.listsView.hide()
binding.messageView.apply {
show()
if (error is IOException) {
setup(R.drawable.elephant_offline, R.string.error_network) {
load()
}
} else {
setup(R.drawable.elephant_error, R.string.error_generic) {
load()
}
}
setup(error) { load() }
}
}
}

View File

@ -51,7 +51,6 @@ import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
/**
@ -133,12 +132,7 @@ class AccountMediaFragment :
}
is LoadState.Error -> {
binding.statusView.show()
if ((loadState.refresh as LoadState.Error).error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null)
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null)
}
binding.statusView.setup((loadState.refresh as LoadState.Error).error)
}
is LoadState.Loading -> {
binding.progressBar.show()

View File

@ -19,14 +19,14 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.commit
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityAccountListBinding
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
class AccountListActivity : BaseActivity(), HasAndroidInjector {
class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>

View File

@ -32,7 +32,10 @@ import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.PostLookupFallbackBehavior
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Type
import com.keylesspalace.tusky.components.accountlist.adapter.AccountAdapter
@ -47,6 +50,7 @@ import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.HttpHeaderLink
@ -60,7 +64,11 @@ import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountActionListener, Injectable {
class AccountListFragment :
Fragment(R.layout.fragment_account_list),
AccountActionListener,
LinkListener,
Injectable {
@Inject
lateinit var api: MastodonApi
@ -107,7 +115,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
instanceName = accountManager.activeAccount!!.domain,
accountLocked = arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true
)
val followRequestsAdapter = FollowRequestsAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
val followRequestsAdapter = FollowRequestsAdapter(this, this, animateAvatar, animateEmojis, showBotOverlay)
binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter)
followRequestsAdapter
}
@ -131,6 +139,11 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
fetchAccounts()
}
override fun onViewTag(tag: String) {
(activity as BaseActivity?)
?.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
}
override fun onViewAccount(id: String) {
(activity as BaseActivity?)?.let {
val intent = AccountActivity.getIntent(it, id)
@ -138,6 +151,10 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
}
}
override fun onViewUrl(url: String) {
(activity as BottomSheetActivity?)?.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER)
}
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) {
viewLifecycleOwner.lifecycleScope.launch {
try {
@ -376,16 +393,9 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
if (adapter.itemCount == 0) {
binding.messageView.show()
if (throwable is IOException) {
binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
binding.messageView.hide()
this.fetchAccounts(null)
}
} else {
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
binding.messageView.hide()
this.fetchAccounts(null)
}
binding.messageView.setup(throwable) {
binding.messageView.hide()
this.fetchAccounts(null)
}
}
}

View File

@ -20,10 +20,12 @@ import android.view.ViewGroup
import com.keylesspalace.tusky.adapter.FollowRequestViewHolder
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.interfaces.LinkListener
/** Displays a list of follow requests with accept/reject buttons. */
class FollowRequestsAdapter(
accountActionListener: AccountActionListener,
private val linkListener: LinkListener,
animateAvatar: Boolean,
animateEmojis: Boolean,
showBotOverlay: Boolean
@ -43,6 +45,7 @@ class FollowRequestsAdapter(
return FollowRequestViewHolder(
binding,
accountActionListener,
linkListener,
showHeader = false
)
}

View File

@ -53,7 +53,9 @@ import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.content.IntentCompat
import androidx.core.content.res.use
import androidx.core.os.BundleCompat
import androidx.core.view.ContentInfoCompat
import androidx.core.view.OnReceiveContentListener
import androidx.core.view.isGone
@ -76,6 +78,7 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.EmojiAdapter
import com.keylesspalace.tusky.adapter.LocaleAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.components.compose.ComposeViewModel.ConfirmationKind
import com.keylesspalace.tusky.components.compose.dialog.CaptionDialog
import com.keylesspalace.tusky.components.compose.dialog.makeFocusDialog
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
@ -142,6 +145,9 @@ class ComposeActivity :
private lateinit var emojiBehavior: BottomSheetBehavior<*>
private lateinit var scheduleBehavior: BottomSheetBehavior<*>
/** The account that is being used to compose the status */
private lateinit var activeAccount: AccountEntity
private var photoUploadUri: Uri? = null
private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) }
@ -208,10 +214,15 @@ class ComposeActivity :
notificationManager.cancel(notificationId)
}
val accountId = intent.getLongExtra(ACCOUNT_ID_EXTRA, -1)
if (accountId != -1L) {
accountManager.setActiveAccount(accountId)
}
// If started from an intent then compose as the account ID from the intent.
// Otherwise use the active account. If null then the user is not logged in,
// and return from the activity.
val intentAccountId = intent.getLongExtra(ACCOUNT_ID_EXTRA, -1)
activeAccount = if (intentAccountId != -1L) {
accountManager.getAccountById(intentAccountId)
} else {
accountManager.activeAccount
} ?: return
val theme = preferences.getString("appTheme", APP_THEME_DEFAULT)
if (theme == "black") {
@ -220,8 +231,6 @@ class ComposeActivity :
setContentView(binding.root)
setupActionBar()
// do not do anything when not logged in, activity will be finished in super.onCreate() anyway
val activeAccount = accountManager.activeAccount ?: return
setupAvatar(activeAccount)
val mediaAdapter = MediaPreviewAdapter(
@ -245,7 +254,7 @@ class ComposeActivity :
/* If the composer is started up as a reply to another post, override the "starting" state
* based on what the intent from the reply request passes. */
val composeOptions: ComposeOptions? = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA)
val composeOptions: ComposeOptions? = IntentCompat.getParcelableExtra(intent, COMPOSE_OPTIONS_EXTRA, ComposeOptions::class.java)
viewModel.setup(composeOptions)
setupButtons()
@ -279,7 +288,7 @@ class ComposeActivity :
/* Finally, overwrite state with data from saved instance state. */
savedInstanceState?.let {
photoUploadUri = it.getParcelable(PHOTO_UPLOAD_URI_KEY)
photoUploadUri = BundleCompat.getParcelable(it, PHOTO_UPLOAD_URI_KEY, Uri::class.java)
(it.getSerializable(VISIBILITY_KEY) as Status.Visibility).apply {
setStatusVisibility(this)
@ -308,12 +317,12 @@ class ComposeActivity :
if (type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) {
when (intent.action) {
Intent.ACTION_SEND -> {
intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let { uri ->
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let { uri ->
pickMedia(uri)
}
}
Intent.ACTION_SEND_MULTIPLE -> {
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.forEach { uri ->
IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.forEach { uri ->
pickMedia(uri)
}
}
@ -934,7 +943,10 @@ class ComposeActivity :
val split = contentInfo.partition { item: ClipData.Item -> item.uri != null }
split.first?.let { content ->
for (i in 0 until content.clip.itemCount) {
pickMedia(content.clip.getItemAt(i).uri)
pickMedia(
content.clip.getItemAt(i).uri,
contentInfo.clip.description.label as String?
)
}
}
return split.second
@ -955,7 +967,7 @@ class ComposeActivity :
enableButtons(true, viewModel.editing)
} else if (characterCount <= maximumTootCharacters) {
lifecycleScope.launch {
viewModel.sendStatus(contentText, spoilerText)
viewModel.sendStatus(contentText, spoilerText, activeAccount.id)
deleteDraftAndFinish()
}
} else {
@ -1055,9 +1067,9 @@ class ComposeActivity :
viewModel.removeMediaFromQueue(item)
}
private fun pickMedia(uri: Uri) {
private fun pickMedia(uri: Uri, description: String? = null) {
lifecycleScope.launch {
viewModel.pickMedia(uri).onFailure { throwable ->
viewModel.pickMedia(uri, description).onFailure { throwable ->
val errorString = when (throwable) {
is FileSizeException -> {
val decimalFormat = DecimalFormat("0.##")
@ -1118,16 +1130,19 @@ class ComposeActivity :
private fun handleCloseButton() {
val contentText = binding.composeEditField.text.toString()
val contentWarning = binding.composeContentWarningField.text.toString()
if (viewModel.didChange(contentText, contentWarning)) {
when (viewModel.composeKind) {
ComposeKind.NEW -> getSaveAsDraftOrDiscardDialog(contentText, contentWarning)
ComposeKind.EDIT_DRAFT -> getUpdateDraftOrDiscardDialog(contentText, contentWarning)
ComposeKind.EDIT_POSTED -> getContinueEditingOrDiscardDialog()
ComposeKind.EDIT_SCHEDULED -> getContinueEditingOrDiscardDialog()
}.show()
} else {
viewModel.stopUploads()
finishWithoutSlideOutAnimation()
when (viewModel.handleCloseButton(contentText, contentWarning)) {
ConfirmationKind.NONE -> {
viewModel.stopUploads()
finishWithoutSlideOutAnimation()
}
ConfirmationKind.SAVE_OR_DISCARD ->
getSaveAsDraftOrDiscardDialog(contentText, contentWarning).show()
ConfirmationKind.UPDATE_OR_DISCARD ->
getUpdateDraftOrDiscardDialog(contentText, contentWarning).show()
ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES ->
getContinueEditingOrDiscardDialog().show()
ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT ->
getDeleteEmptyDraftOrContinueEditing().show()
}
}
@ -1192,6 +1207,23 @@ class ComposeActivity :
}
}
/**
* User is editing an existing draft and making it empty.
* The user can either delete the empty draft or go back to editing.
*/
private fun getDeleteEmptyDraftOrContinueEditing(): AlertDialog.Builder {
return AlertDialog.Builder(this)
.setMessage(R.string.compose_delete_draft)
.setPositiveButton(R.string.action_delete) { _, _ ->
viewModel.deleteDraft()
viewModel.stopUploads()
finishWithoutSlideOutAnimation()
}
.setNegativeButton(R.string.action_continue_edit) { _, _ ->
// Do nothing, dialog will dismiss, user can continue editing
}
}
private fun deleteDraftAndFinish() {
viewModel.deleteDraft()
finishWithoutSlideOutAnimation()

View File

@ -21,6 +21,7 @@ import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeKind
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult
import com.keylesspalace.tusky.components.drafts.DraftHelper
@ -94,7 +95,7 @@ class ComposeViewModel @Inject constructor(
val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
val uploadError = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
lateinit var composeKind: ComposeActivity.ComposeKind
lateinit var composeKind: ComposeKind
// Used in ComposeActivity to pass state to result function when cropImage contract inflight
var cropImageItemOld: QueuedMedia? = null
@ -213,7 +214,28 @@ class ComposeViewModel @Inject constructor(
this.markMediaAsSensitive.value = this.markMediaAsSensitive.value != true
}
fun didChange(content: String?, contentWarning: String?): Boolean {
fun handleCloseButton(contentText: String?, contentWarning: String?): ConfirmationKind {
return if (didChange(contentText, contentWarning)) {
when (composeKind) {
ComposeKind.NEW -> if (isEmpty(contentText, contentWarning)) {
ConfirmationKind.NONE
} else {
ConfirmationKind.SAVE_OR_DISCARD
}
ComposeKind.EDIT_DRAFT -> if (isEmpty(contentText, contentWarning)) {
ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT
} else {
ConfirmationKind.UPDATE_OR_DISCARD
}
ComposeKind.EDIT_POSTED -> ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES
ComposeKind.EDIT_SCHEDULED -> ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES
}
} else {
ConfirmationKind.NONE
}
}
private fun didChange(content: String?, contentWarning: String?): Boolean {
val textChanged = content.orEmpty() != startingText.orEmpty()
val contentWarningChanged = contentWarning.orEmpty() != startingContentWarning
val mediaChanged = media.value.isNotEmpty()
@ -223,6 +245,10 @@ class ComposeViewModel @Inject constructor(
return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged || didScheduledTimeChange
}
private fun isEmpty(content: String?, contentWarning: String?): Boolean {
return !modifiedInitialState && (content.isNullOrBlank() && contentWarning.isNullOrBlank() && media.value.isEmpty() && poll.value == null)
}
fun contentWarningChanged(value: Boolean) {
showContentWarning.value = value
contentWarningStateChanged = true
@ -283,7 +309,8 @@ class ComposeViewModel @Inject constructor(
*/
suspend fun sendStatus(
content: String,
spoilerText: String
spoilerText: String,
accountId: Long
) {
if (!scheduledTootId.isNullOrEmpty()) {
api.deleteScheduledStatus(scheduledTootId!!)
@ -310,7 +337,7 @@ class ComposeViewModel @Inject constructor(
poll = poll.value,
replyingStatusContent = null,
replyingStatusAuthorUsername = null,
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
draftId = draftId,
idempotencyKey = randomAlphanumericString(16),
retries = 0,
@ -389,7 +416,7 @@ class ComposeViewModel @Inject constructor(
return
}
composeKind = composeOptions?.kind ?: ComposeActivity.ComposeKind.NEW
composeKind = composeOptions?.kind ?: ComposeKind.NEW
val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy
@ -485,6 +512,14 @@ class ComposeViewModel @Inject constructor(
private companion object {
const val TAG = "ComposeViewModel"
}
enum class ConfirmationKind {
NONE, // just close
SAVE_OR_DISCARD,
UPDATE_OR_DISCARD,
CONTINUE_EDITING_OR_DISCARD_CHANGES, // editing post
CONTINUE_EDITING_OR_DISCARD_DRAFT // edit draft
}
}
/**

View File

@ -27,6 +27,7 @@ import android.view.ViewGroup
import android.view.WindowManager
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
import androidx.core.os.BundleCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import com.bumptech.glide.Glide
@ -73,7 +74,7 @@ class CaptionDialog : DialogFragment() {
val window = dialog.window
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
val previewUri = arguments?.getParcelable<Uri>(PREVIEW_URI_ARG) ?: error("Preview Uri is null")
val previewUri = BundleCompat.getParcelable(requireArguments(), PREVIEW_URI_ARG, Uri::class.java) ?: error("Preview Uri is null")
// Load the image and manually set it into the ImageView because it doesn't have a fixed size.
Glide.with(this)
.load(previewUri)

View File

@ -15,13 +15,13 @@
package com.keylesspalace.tusky.components.compose.dialog
import android.app.Activity
import android.content.DialogInterface
import android.graphics.drawable.Drawable
import android.net.Uri
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
@ -38,7 +38,7 @@ fun <T> T.makeFocusDialog(
existingFocus: Focus?,
previewUri: Uri,
onUpdateFocus: suspend (Focus) -> Unit
) where T : Activity, T : LifecycleOwner {
) where T : AppCompatActivity, T : LifecycleOwner {
val focus = existingFocus ?: Focus(0.0f, 0.0f) // Default to center
val dialogBinding = DialogFocusBinding.inflate(layoutInflater)

View File

@ -89,7 +89,7 @@ data class ConversationStatusEntity(
val bookmarked: Boolean,
val sensitive: Boolean,
val spoilerText: String,
val attachments: ArrayList<Attachment>,
val attachments: List<Attachment>,
val mentions: List<Status.Mention>,
val tags: List<HashTag>?,
val showingHiddenContent: Boolean,

View File

@ -64,7 +64,6 @@ import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
import kotlin.time.DurationUnit
import kotlin.time.toDuration
@ -139,16 +138,7 @@ class ConversationsFragment :
}
is LoadState.Error -> {
binding.statusView.show()
if ((loadState.refresh as LoadState.Error).error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
refreshContent()
}
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
refreshContent()
}
}
binding.statusView.setup((loadState.refresh as LoadState.Error).error) { refreshContent() }
}
is LoadState.Loading -> {
binding.progressBar.show()

View File

@ -7,9 +7,11 @@ import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.content.IntentCompat
import androidx.core.view.size
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope
import at.connyduck.calladapter.networkresult.fold
import com.google.android.material.chip.Chip
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.switchmaterial.SwitchMaterial
@ -23,7 +25,9 @@ import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterKeyword
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.util.Date
import javax.inject.Inject
@ -47,7 +51,7 @@ class EditFilterActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
originalFilter = intent?.getParcelableExtra(FILTER_TO_EDIT)
originalFilter = IntentCompat.getParcelableExtra(intent, FILTER_TO_EDIT, Filter::class.java)
filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN.action, listOf())
binding.apply {
contextSwitches = mapOf(
@ -77,6 +81,9 @@ class EditFilterActivity : BaseActivity() {
binding.actionChip.setOnClickListener { showAddKeywordDialog() }
binding.filterSaveButton.setOnClickListener { saveChanges() }
binding.filterDeleteButton.setOnClickListener { deleteFilter() }
binding.filterDeleteButton.visible(originalFilter != null)
for (switch in contextSwitches.keys) {
switch.setOnCheckedChangeListener { _, isChecked ->
val context = contextSwitches[switch]!!
@ -258,6 +265,32 @@ class EditFilterActivity : BaseActivity() {
}
}
private fun deleteFilter() {
originalFilter?.let { filter ->
lifecycleScope.launch {
api.deleteFilter(filter.id).fold(
{
finish()
},
{ throwable ->
if (throwable is HttpException && throwable.code() == 404) {
api.deleteFilterV1(filter.id).fold(
{
finish()
},
{
Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
}
)
} else {
Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
}
}
)
}
}
}
companion object {
const val FILTER_TO_EDIT = "FilterToEdit"

View File

@ -82,7 +82,6 @@ class FiltersActivity : BaseActivity(), FiltersListener {
} else {
binding.messageView.hide()
binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters)
binding.filtersList.show()
}
}
}
@ -91,7 +90,6 @@ class FiltersActivity : BaseActivity(), FiltersListener {
}
private fun loadFilters() {
binding.filtersList.hide()
viewModel.load()
}

View File

@ -31,7 +31,6 @@ import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class FollowedTagsActivity :
@ -108,11 +107,7 @@ class FollowedTagsActivity :
binding.followedTagsView.hide()
binding.followedTagsMessageView.show()
val errorState = loadState.refresh as LoadState.Error
if (errorState.error is IOException) {
binding.followedTagsMessageView.setup(R.drawable.elephant_offline, R.string.error_network) { retry() }
} else {
binding.followedTagsMessageView.setup(R.drawable.elephant_error, R.string.error_generic) { retry() }
}
binding.followedTagsMessageView.setup(errorState.error) { retry() }
Log.w(TAG, "error loading followed hashtags", errorState.error)
} else {
binding.followedTagsView.show()

View File

@ -26,7 +26,6 @@ import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.EndlessOnScrollListener
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener {
@ -146,16 +145,9 @@ class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectab
if (adapter.itemCount == 0) {
binding.messageView.show()
if (throwable is IOException) {
binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
binding.messageView.hide()
this.fetchInstances(null)
}
} else {
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
binding.messageView.hide()
this.fetchInstances(null)
}
binding.messageView.setup(throwable) {
binding.messageView.hide()
this.fetchInstances(null)
}
}
}

View File

@ -33,6 +33,7 @@ import android.webkit.WebViewClient
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import com.keylesspalace.tusky.BaseActivity
@ -61,7 +62,9 @@ class OauthLogin : ActivityResultContract<LoginData, LoginResult>() {
return if (resultCode == Activity.RESULT_CANCELED) {
LoginResult.Cancel
} else {
intent!!.getParcelableExtra(RESULT_EXTRA)!!
intent?.let {
IntentCompat.getParcelableExtra(it, RESULT_EXTRA, LoginResult::class.java)
} ?: LoginResult.Err("failed parsing LoginWebViewActivity result")
}
}
@ -70,7 +73,7 @@ class OauthLogin : ActivityResultContract<LoginData, LoginResult>() {
private const val DATA_EXTRA = "data"
fun parseData(intent: Intent): LoginData {
return intent.getParcelableExtra(DATA_EXTRA)!!
return IntentCompat.getParcelableExtra(intent, DATA_EXTRA, LoginData::class.java)!!
}
fun makeResultIntent(result: LoginResult): Intent {

View File

@ -22,16 +22,19 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemFollowBinding
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.viewdata.NotificationViewData
class FollowViewHolder(
private val binding: ItemFollowBinding,
private val notificationActionListener: NotificationActionListener
private val notificationActionListener: NotificationActionListener,
private val linkListener: LinkListener
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
private val avatarRadius42dp = itemView.context.resources.getDimensionPixelSize(
R.dimen.avatar_radius_42dp
@ -94,11 +97,12 @@ class FollowViewHolder(
animateAvatars
)
binding.notificationAccountNote.text = account.note.parseAsMastodonHtml().emojify(
val emojifiedNote = account.note.parseAsMastodonHtml().emojify(
account.emojis,
binding.notificationAccountNote,
animateEmojis
)
setClickableText(binding.notificationAccountNote, emojifiedNote, emptyList(), null, linkListener)
}
private fun setupButtons(listener: NotificationActionListener, accountId: String) {

View File

@ -1,75 +1,190 @@
package com.keylesspalace.tusky.components.notifications
import android.app.NotificationManager
import android.content.Context
import android.util.Log
import androidx.annotation.WorkerThread
import com.keylesspalace.tusky.components.notifications.NotificationHelper.filterNotification
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Marker
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isLessThan
import kotlinx.coroutines.delay
import javax.inject.Inject
import kotlin.math.min
import kotlin.time.Duration.Companion.milliseconds
/**
* Fetch Mastodon notifications and show Android notifications, with summaries, for them.
*
* Should only be called by a worker thread.
*
* @see NotificationWorker
* @see <a href="https://developer.android.com/guide/background/persistent/threading/worker">Background worker</a>
*/
@WorkerThread
class NotificationFetcher @Inject constructor(
private val mastodonApi: MastodonApi,
private val accountManager: AccountManager,
private val context: Context
) {
fun fetchAndShow() {
suspend fun fetchAndShow() {
for (account in accountManager.getAllAccountsOrderedByActive()) {
if (account.notificationsEnabled) {
try {
val notifications = fetchNotifications(account)
notifications.forEachIndexed { index, notification ->
NotificationHelper.make(context, notification, account, index == 0)
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Create sorted list of new notifications
val notifications = fetchNewNotifications(account)
.filter { filterNotification(notificationManager, account, it) }
.sortedWith(compareBy({ it.id.length }, { it.id })) // oldest notifications first
.toMutableList()
// There's a maximum limit on the number of notifications an Android app
// can display. If the total number of notifications (current notifications,
// plus new ones) exceeds this then some newer notifications will be dropped.
//
// Err on the side of removing *older* notifications to make room for newer
// notifications.
val currentAndroidNotifications = notificationManager.activeNotifications
.sortedWith(compareBy({ it.tag.length }, { it.tag })) // oldest notifications first
// Check to see if any notifications need to be removed
val toRemove = currentAndroidNotifications.size + notifications.size - MAX_NOTIFICATIONS
if (toRemove > 0) {
// Prefer to cancel old notifications first
currentAndroidNotifications.subList(0, min(toRemove, currentAndroidNotifications.size))
.forEach { notificationManager.cancel(it.tag, it.id) }
// Still got notifications to remove? Trim the list of new notifications,
// starting with the oldest.
while (notifications.size > MAX_NOTIFICATIONS) {
notifications.removeAt(0)
}
}
// Make and send the new notifications
// TODO: Use the batch notification API available in NotificationManagerCompat
// 1.11 and up (https://developer.android.com/jetpack/androidx/releases/core#1.11.0-alpha01)
// when it is released.
notifications.forEachIndexed { index, notification ->
val androidNotification = NotificationHelper.make(
context,
notificationManager,
notification,
account,
index == 0
)
notificationManager.notify(notification.id, account.id.toInt(), androidNotification)
// Android will rate limit / drop notifications if they're posted too
// quickly. There is no indication to the user that this happened.
// See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664
delay(1000.milliseconds)
}
NotificationHelper.updateSummaryNotifications(
context,
notificationManager,
account
)
accountManager.saveAccount(account)
} catch (e: Exception) {
Log.w(TAG, "Error while fetching notifications", e)
Log.e(TAG, "Error while fetching notifications", e)
}
}
}
}
private fun fetchNotifications(account: AccountEntity): MutableList<Notification> {
/**
* Fetch new Mastodon Notifications and update the marker position.
*
* Here, "new" means "notifications with IDs newer than notifications the user has already
* seen."
*
* The "water mark" for Mastodon Notification IDs are stored in three places.
*
* - acccount.lastNotificationId -- the ID of the top-most notification when the user last
* left the Notifications tab.
* - The Mastodon "marker" API -- the ID of the most recent notification fetched here.
* - account.notificationMarkerId -- local version of the value from the Mastodon marker
* API, in case the Mastodon server does not implement that API.
*
* The user may have refreshed the "Notifications" tab and seen notifications newer than the
* ones that were last fetched here. So `lastNotificationId` takes precedence if it is greater
* than the marker.
*/
private suspend fun fetchNewNotifications(account: AccountEntity): List<Notification> {
val authHeader = String.format("Bearer %s", account.accessToken)
// We fetch marker to not load/show notifications which user has already seen
val marker = fetchMarker(authHeader, account)
if (marker != null && account.lastNotificationId.isLessThan(marker.lastReadId)) {
account.lastNotificationId = marker.lastReadId
}
Log.d(TAG, "getting Notifications for " + account.fullName)
val notifications = mastodonApi.notificationsWithAuth(
authHeader,
account.domain,
account.lastNotificationId
).blockingGet()
val newId = account.lastNotificationId
var newestId = ""
val result = mutableListOf<Notification>()
for (notification in notifications.reversed()) {
val currentId = notification.id
if (newestId.isLessThan(currentId)) {
newestId = currentId
account.lastNotificationId = currentId
}
if (newId.isLessThan(currentId)) {
result.add(notification)
// Figure out where to read from. Choose the most recent notification ID from:
//
// - The Mastodon marker API (if the server supports it)
// - account.notificationMarkerId
// - account.lastNotificationId
Log.d(TAG, "getting notification marker for ${account.fullName}")
val remoteMarkerId = fetchMarker(authHeader, account)?.lastReadId ?: "0"
val localMarkerId = account.notificationMarkerId
val markerId = if (remoteMarkerId.isLessThan(localMarkerId)) localMarkerId else remoteMarkerId
val readingPosition = account.lastNotificationId
var minId: String? = if (readingPosition.isLessThan(markerId)) markerId else readingPosition
Log.d(TAG, " remoteMarkerId: $remoteMarkerId")
Log.d(TAG, " localMarkerId: $localMarkerId")
Log.d(TAG, " readingPosition: $readingPosition")
Log.d(TAG, "getting Notifications for ${account.fullName}, min_id: $minId")
// Fetch all outstanding notifications
val notifications = buildList {
while (minId != null) {
val response = mastodonApi.notificationsWithAuth(
authHeader,
account.domain,
minId = minId
)
if (!response.isSuccessful) break
// Notifications are returned in the page in order, newest first,
// (https://github.com/mastodon/documentation/issues/1226), insert the
// new page at the head of the list.
response.body()?.let { addAll(0, it) }
// Get the previous page, which will be chronologically newer
// notifications. If it doesn't exist this is null and the loop
// will exit.
val links = Links.from(response.headers()["link"])
minId = links.prev
}
}
return result
// Save the newest notification ID in the marker.
notifications.firstOrNull()?.let {
val newMarkerId = notifications.first().id
Log.d(TAG, "updating notification marker for ${account.fullName} to: $newMarkerId")
mastodonApi.updateMarkersWithAuth(
auth = authHeader,
domain = account.domain,
notificationsLastReadId = newMarkerId
)
account.notificationMarkerId = newMarkerId
accountManager.saveAccount(account)
}
return notifications
}
private fun fetchMarker(authHeader: String, account: AccountEntity): Marker? {
private suspend fun fetchMarker(authHeader: String, account: AccountEntity): Marker? {
return try {
val allMarkers = mastodonApi.markersWithAuth(
authHeader,
account.domain,
listOf("notifications")
).blockingGet()
)
val notificationMarker = allMarkers["notifications"]
Log.d(TAG, "Fetched marker: $notificationMarker")
Log.d(TAG, "Fetched marker for ${account.fullName}: $notificationMarker")
notificationMarker
} catch (e: Exception) {
Log.e(TAG, "Failed to fetch marker", e)
@ -78,6 +193,13 @@ class NotificationFetcher @Inject constructor(
}
companion object {
const val TAG = "NotificationFetcher"
private const val TAG = "NotificationFetcher"
// There's a system limit on the maximum number of notifications an app
// can show, NotificationManagerService.MAX_PACKAGE_NOTIFICATIONS. Unfortunately
// that's not available to client code or via the NotificationManager API.
// The current value in the Android source code is 50, set 40 here to both
// be conservative, and allow some headroom for summary notifications.
private const val MAX_NOTIFICATIONS = 40
}
}

View File

@ -16,6 +16,7 @@
package com.keylesspalace.tusky.components.notifications;
import static com.keylesspalace.tusky.BuildConfig.APPLICATION_ID;
import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml;
import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription;
@ -28,18 +29,22 @@ import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Build;
import android.os.Bundle;
import android.provider.Settings;
import android.service.notification.StatusBarNotification;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.app.RemoteInput;
import androidx.core.app.TaskStackBuilder;
import androidx.work.Constraints;
import androidx.work.NetworkType;
import androidx.work.OneTimeWorkRequest;
import androidx.work.OutOfQuotaPolicy;
import androidx.work.PeriodicWorkRequest;
import androidx.work.WorkManager;
import androidx.work.WorkRequest;
@ -56,35 +61,36 @@ 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.receiver.NotificationClearBroadcastReceiver;
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver;
import com.keylesspalace.tusky.util.StringUtils;
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
import org.json.JSONArray;
import org.json.JSONException;
import com.keylesspalace.tusky.worker.NotificationWorker;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class NotificationHelper {
private static int notificationId = 0;
/** ID of notification shown when fetching notifications */
public static final int NOTIFICATION_ID_FETCH_NOTIFICATION = 0;
/** ID of notification shown when pruning the cache */
public static final int NOTIFICATION_ID_PRUNE_CACHE = 1;
/** Dynamic notification IDs start here */
private static int notificationId = NOTIFICATION_ID_PRUNE_CACHE + 1;
/**
* constants used in Intents
*/
public static final String ACCOUNT_ID = "account_id";
public static final String TYPE = "type";
public static final String TYPE = APPLICATION_ID + ".notification.type";
private static final String TAG = "NotificationHelper";
@ -121,57 +127,60 @@ public class NotificationHelper {
public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP";
public static final String CHANNEL_UPDATES = "CHANNEL_UPDATES";
public static final String CHANNEL_REPORT = "CHANNEL_REPORT";
public static final String CHANNEL_BACKGROUND_TASKS = "CHANNEL_BACKGROUND_TASKS";
/**
* WorkManager Tag
*/
private static final String NOTIFICATION_PULL_TAG = "pullNotifications";
/** Tag for the summary notification */
private static final String GROUP_SUMMARY_TAG = APPLICATION_ID + ".notification.group_summary";
/** The name of the account that caused the notification, for use in a summary */
private static final String EXTRA_ACCOUNT_NAME = APPLICATION_ID + ".notification.extra.account_name";
/** The notification's type (string representation of a Notification.Type) */
private static final String EXTRA_NOTIFICATION_TYPE = APPLICATION_ID + ".notification.extra.notification_type";
/**
* Takes a given Mastodon notification and either creates a new Android notification or updates
* the state of the existing notification to reflect the new interaction.
* Takes a given Mastodon notification and creates a new Android notification or updates the
* existing Android notification.
* <p>
* The Android notification has it's tag set to the Mastodon notification ID, and it's ID set
* to the ID of the account that received the notification.
*
* @param context to access application preferences and services
* @param body a new Mastodon notification
* @param account the account for which the notification should be shown
* @return the new notification
*/
public static void make(final Context context, Notification body, AccountEntity account, boolean isFirstOfBatch) {
@NonNull
public static android.app.Notification make(final Context context, NotificationManager notificationManager, Notification body, AccountEntity account, boolean isFirstOfBatch) {
body = body.rewriteToStatusTypeIfNeeded(account.getAccountId());
String mastodonNotificationId = body.getId();
int accountId = (int) account.getId();
if (!filterNotification(account, body, context)) {
return;
}
String rawCurrentNotifications = account.getActiveNotifications();
JSONArray currentNotifications;
try {
currentNotifications = new JSONArray(rawCurrentNotifications);
} catch (JSONException e) {
currentNotifications = new JSONArray();
}
for (int i = 0; i < currentNotifications.length(); i++) {
try {
if (currentNotifications.getString(i).equals(body.getAccount().getName())) {
currentNotifications.remove(i);
break;
}
} catch (JSONException e) {
Log.d(TAG, Log.getStackTraceString(e));
// Check for an existing notification with this Mastodon Notification ID
android.app.Notification existingAndroidNotification = null;
StatusBarNotification[] activeNotifications = notificationManager.getActiveNotifications();
for (StatusBarNotification androidNotification : activeNotifications) {
if (mastodonNotificationId.equals(androidNotification.getTag()) && accountId == androidNotification.getId()) {
existingAndroidNotification = androidNotification.getNotification();
}
}
currentNotifications.put(body.getAccount().getName());
account.setActiveNotifications(currentNotifications.toString());
// Notification group member
// =========================
final NotificationCompat.Builder builder = newNotification(context, body, account, false);
notificationId++;
// Create the notification -- either create a new one, or use the existing one.
NotificationCompat.Builder builder;
if (existingAndroidNotification == null) {
builder = newAndroidNotification(context, body, account);
} else {
builder = new NotificationCompat.Builder(context, existingAndroidNotification);
}
builder.setContentTitle(titleForType(context, body, account))
.setContentText(bodyForType(body, context, account.getAlwaysOpenSpoiler()));
@ -233,51 +242,136 @@ public class NotificationHelper {
builder.setCategory(NotificationCompat.CATEGORY_SOCIAL);
builder.setOnlyAlertOnce(true);
// only alert for the first notification of a batch to avoid multiple alerts at once
Bundle extras = new Bundle();
// Add the sending account's name, so it can be used when summarising this notification
extras.putString(EXTRA_ACCOUNT_NAME, body.getAccount().getName());
extras.putString(EXTRA_NOTIFICATION_TYPE, body.getType().toString());
builder.addExtras(extras);
// Only alert for the first notification of a batch to avoid multiple alerts at once
if(!isFirstOfBatch) {
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY);
}
// Summary
final NotificationCompat.Builder summaryBuilder = newNotification(context, body, account, true);
return builder.build();
}
if (currentNotifications.length() != 1) {
try {
String title = context.getResources().getQuantityString(R.plurals.notification_title_summary, currentNotifications.length(), currentNotifications.length());
String text = joinNames(context, currentNotifications);
summaryBuilder.setContentTitle(title)
.setContentText(text);
} catch (JSONException e) {
Log.d(TAG, Log.getStackTraceString(e));
}
/**
* Updates the summary notifications for each notification group.
* <p>
* Notifications are sent to channels. Within each channel they may be grouped, and the group
* may have a summary.
* <p>
* Tusky uses N notification channels for each account, each channel corresponds to a type
* of notification (follow, reblog, mention, etc). Therefore each channel also has exactly
* 0 or 1 summary notifications along with its regular notifications.
* <p>
* The group key is the same as the channel ID.
* <p>
* Regnerates the summary notifications for all active Tusky notifications for `account`.
* This may delete the summary notification if there are no active notifications for that
* account in a group.
*
* @see <a href="https://developer.android.com/develop/ui/views/notifications/group">Create a
* notification group</a>
* @param context to access application preferences and services
* @param notificationManager the system's NotificationManager
* @param account the account for which the notification should be shown
*/
public static void updateSummaryNotifications(Context context, NotificationManager notificationManager, AccountEntity account) {
// Map from the channel ID to a list of notifications in that channel. Those are the
// notifications that will be summarised.
Map<String, List<StatusBarNotification>> channelGroups = new HashMap<>();
int accountId = (int) account.getId();
// Initialise the map with all channel IDs.
for (Notification.Type ty : Notification.Type.values()) {
channelGroups.put(getChannelId(account, ty), new ArrayList<>());
}
summaryBuilder.setSubText(account.getFullName());
summaryBuilder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE);
summaryBuilder.setCategory(NotificationCompat.CATEGORY_SOCIAL);
summaryBuilder.setOnlyAlertOnce(true);
summaryBuilder.setGroupSummary(true);
// Fetch all existing notifications. Add them to the map, ignoring notifications that:
// - belong to a different account
// - are summary notifications
for (StatusBarNotification sn : notificationManager.getActiveNotifications()) {
if (sn.getId() != accountId) continue;
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
String channelId = sn.getNotification().getGroup();
String summaryTag = GROUP_SUMMARY_TAG + "." + channelId;
if (summaryTag.equals(sn.getTag())) continue;
notificationManager.notify(notificationId, builder.build());
if (currentNotifications.length() == 1) {
notificationManager.notify((int) account.getId(), builder.setGroupSummary(true).build());
} else {
notificationManager.notify((int) account.getId(), summaryBuilder.build());
// TODO: API 26 supports getting the channel ID directly (sn.getNotification().getChannelId()).
// This works here because the channelId and the groupKey are the same.
List<StatusBarNotification> members = channelGroups.get(channelId);
if (members == null) { // can't happen, but just in case...
Log.e(TAG, "members == null for channel ID " + channelId);
continue;
}
members.add(sn);
}
// Create, update, or cancel the summary notifications for each group.
for (Map.Entry<String, List<StatusBarNotification>> channelGroup : channelGroups.entrySet()) {
String channelId = channelGroup.getKey();
List<StatusBarNotification> members = channelGroup.getValue();
String summaryTag = GROUP_SUMMARY_TAG + "." + channelId;
// If there are 0-1 notifications in this group then the additional summary
// notification is not needed and can be cancelled.
if (members.size() <= 1) {
notificationManager.cancel(summaryTag, accountId);
continue;
}
// Create a notification that summarises the other notifications in this group
// All notifications in this group have the same type, so get it from the first.
String notificationType = members.get(0).getNotification().extras.getString(EXTRA_NOTIFICATION_TYPE);
Intent summaryResultIntent = new Intent(context, MainActivity.class);
summaryResultIntent.putExtra(ACCOUNT_ID, (long) accountId);
summaryResultIntent.putExtra(TYPE, notificationType);
TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context);
summaryStackBuilder.addParentStack(MainActivity.class);
summaryStackBuilder.addNextIntent(summaryResultIntent);
PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent((int) (notificationId + account.getId() * 10000),
pendingIntentFlags(false));
String title = context.getResources().getQuantityString(R.plurals.notification_title_summary, members.size(), members.size());
String text = joinNames(context, members);
NotificationCompat.Builder summaryBuilder = new NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notify)
.setContentIntent(summaryResultPendingIntent)
.setColor(context.getColor(R.color.notification_color))
.setAutoCancel(true)
.setShortcutId(Long.toString(account.getId()))
.setDefaults(0) // So it doesn't ring twice, notify only in Target callback
.setContentTitle(title)
.setContentText(text)
.setSubText(account.getFullName())
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
.setOnlyAlertOnce(true)
.setGroup(channelId)
.setGroupSummary(true);
setSoundVibrationLight(account, summaryBuilder);
// TODO: Use the batch notification API available in NotificationManagerCompat
// 1.11 and up (https://developer.android.com/jetpack/androidx/releases/core#1.11.0-alpha01)
// when it is released.
notificationManager.notify(summaryTag, accountId, summaryBuilder.build());
// Android will rate limit / drop notifications if they're posted too
// quickly. There is no indication to the user that this happened.
// See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664
try { Thread.sleep(1000); } catch (InterruptedException ignored) { }
}
}
private static NotificationCompat.Builder newNotification(Context context, Notification body, AccountEntity account, boolean summary) {
Intent summaryResultIntent = new Intent(context, MainActivity.class);
summaryResultIntent.putExtra(ACCOUNT_ID, account.getId());
summaryResultIntent.putExtra(TYPE, body.getType().name());
TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context);
summaryStackBuilder.addParentStack(MainActivity.class);
summaryStackBuilder.addNextIntent(summaryResultIntent);
PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent((int) (notificationId + account.getId() * 10000),
pendingIntentFlags(false));
private static NotificationCompat.Builder newAndroidNotification(Context context, Notification body, AccountEntity account) {
// we have to switch account here
Intent eventResultIntent = new Intent(context, MainActivity.class);
@ -290,22 +384,19 @@ public class NotificationHelper {
PendingIntent eventResultPendingIntent = eventStackBuilder.getPendingIntent((int) account.getId(),
pendingIntentFlags(false));
Intent deleteIntent = new Intent(context, NotificationClearBroadcastReceiver.class);
deleteIntent.putExtra(ACCOUNT_ID, account.getId());
PendingIntent deletePendingIntent = PendingIntent.getBroadcast(context, summary ? (int) account.getId() : notificationId, deleteIntent,
pendingIntentFlags(false));
String channelId = getChannelId(account, body);
assert channelId != null;
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, getChannelId(account, body))
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notify)
.setContentIntent(summary ? summaryResultPendingIntent : eventResultPendingIntent)
.setDeleteIntent(deletePendingIntent)
.setContentIntent(eventResultPendingIntent)
.setColor(context.getColor(R.color.notification_color))
.setGroup(account.getAccountId())
.setGroup(channelId)
.setAutoCancel(true)
.setShortcutId(Long.toString(account.getId()))
.setDefaults(0); // So it doesn't ring twice, notify only in Target callback
setupPreferences(account, builder);
setSoundVibrationLight(account, builder);
return builder;
}
@ -388,6 +479,49 @@ public class NotificationHelper {
pendingIntentFlags(false));
}
/**
* Creates a notification channel for notifications for background work that should not
* disturb the user.
*
* @param context context
*/
public static void createWorkerNotificationChannel(@NonNull Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationChannel channel = new NotificationChannel(
CHANNEL_BACKGROUND_TASKS,
context.getString(R.string.notification_listenable_worker_name),
NotificationManager.IMPORTANCE_NONE
);
channel.setDescription(context.getString(R.string.notification_listenable_worker_description));
channel.enableLights(false);
channel.enableVibration(false);
channel.setShowBadge(false);
notificationManager.createNotificationChannel(channel);
}
/**
* Creates a notification for a background worker.
*
* @param context context
* @param titleResource String resource to use as the notification's title
* @return the notification
*/
@NonNull
public static android.app.Notification createWorkerNotification(@NonNull Context context, @StringRes int titleResource) {
String title = context.getString(titleResource);
return new NotificationCompat.Builder(context, CHANNEL_BACKGROUND_TASKS)
.setContentTitle(title)
.setTicker(title)
.setSmallIcon(R.drawable.ic_notify)
.setOngoing(true)
.build();
}
public static void createNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -453,7 +587,6 @@ public class NotificationHelper {
}
notificationManager.createNotificationChannels(channels);
}
}
@ -495,6 +628,15 @@ public class NotificationHelper {
WorkManager workManager = WorkManager.getInstance(context);
workManager.cancelAllWorkByTag(NOTIFICATION_PULL_TAG);
// Periodic work requests are supposed to start running soon after being enqueued. In
// practice that may not be soon enough, so create and enqueue an expedited one-time
// request to get new notifications immediately.
WorkRequest fetchNotifications = new OneTimeWorkRequest.Builder(NotificationWorker.class)
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
.setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build();
workManager.enqueue(fetchNotifications);
WorkRequest workRequest = new PeriodicWorkRequest.Builder(
NotificationWorker.class,
PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS, TimeUnit.MILLISECONDS,
@ -502,6 +644,7 @@ public class NotificationHelper {
)
.addTag(NOTIFICATION_PULL_TAG)
.setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setInitialDelay(5, TimeUnit.MINUTES)
.build();
workManager.enqueue(workRequest);
@ -514,33 +657,23 @@ public class NotificationHelper {
Log.d(TAG, "disabled notification checks");
}
public static void clearNotificationsForActiveAccount(@NonNull Context context, @NonNull AccountManager accountManager) {
AccountEntity account = accountManager.getActiveAccount();
if (account != null && !account.getActiveNotifications().equals("[]")) {
Single.fromCallable(() -> {
account.setActiveNotifications("[]");
accountManager.saveAccount(account);
public static void clearNotificationsForAccount(@NonNull Context context, @NonNull AccountEntity account) {
int accountId = (int) account.getId();
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.cancel((int) account.getId());
return true;
})
.subscribeOn(Schedulers.io())
.subscribe();
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
for (StatusBarNotification androidNotification : notificationManager.getActiveNotifications()) {
if (accountId == androidNotification.getId()) {
notificationManager.cancel(androidNotification.getTag(), androidNotification.getId());
}
}
}
public static boolean filterNotification(AccountEntity account, Notification notification,
Context context) {
return filterNotification(account, notification.getType(), context);
public static boolean filterNotification(NotificationManager notificationManager, AccountEntity account, @NonNull Notification notification) {
return filterNotification(notificationManager, account, notification.getType());
}
public static boolean filterNotification(AccountEntity account, Notification.Type type,
Context context) {
public static boolean filterNotification(@NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification.Type type) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
String channelId = getChannelId(account, type);
if(channelId == null) {
// unknown notificationtype
@ -610,9 +743,7 @@ public class NotificationHelper {
}
private static void setupPreferences(AccountEntity account,
NotificationCompat.Builder builder) {
private static void setSoundVibrationLight(AccountEntity account, NotificationCompat.Builder builder) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
return; //do nothing on Android O or newer, the system uses the channel settings anyway
}
@ -630,28 +761,29 @@ public class NotificationHelper {
}
}
private static String wrapItemAt(JSONArray array, int index) throws JSONException {
return StringUtils.unicodeWrap(array.get(index).toString());
private static String wrapItemAt(StatusBarNotification notification) {
return StringUtils.unicodeWrap(notification.getNotification().extras.getString(EXTRA_ACCOUNT_NAME));//getAccount().getName());
}
@Nullable
private static String joinNames(Context context, JSONArray array) throws JSONException {
if (array.length() > 3) {
int length = array.length();
private static String joinNames(Context context, List<StatusBarNotification> notifications) {
if (notifications.size() > 3) {
int length = notifications.size();
//notifications.get(0).getNotification().extras.getString(EXTRA_ACCOUNT_NAME);
return String.format(context.getString(R.string.notification_summary_large),
wrapItemAt(array, length - 1),
wrapItemAt(array, length - 2),
wrapItemAt(array, length - 3),
wrapItemAt(notifications.get(length - 1)),
wrapItemAt(notifications.get(length - 2)),
wrapItemAt(notifications.get(length - 3)),
length - 3);
} else if (array.length() == 3) {
} else if (notifications.size() == 3) {
return String.format(context.getString(R.string.notification_summary_medium),
wrapItemAt(array, 2),
wrapItemAt(array, 1),
wrapItemAt(array, 0));
} else if (array.length() == 2) {
wrapItemAt(notifications.get(2)),
wrapItemAt(notifications.get(1)),
wrapItemAt(notifications.get(0)));
} else if (notifications.size() == 2) {
return String.format(context.getString(R.string.notification_summary_small),
wrapItemAt(array, 1),
wrapItemAt(array, 0));
wrapItemAt(notifications.get(1)),
wrapItemAt(notifications.get(0)));
}
return null;

View File

@ -1,51 +0,0 @@
/* Copyright 2020 Tusky Contributors
*
* This file is part of Tusky.
*
* Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU
* Lesser 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 Lesser
* General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License along with Tusky. If
* not, see <http://www.gnu.org/licenses/>. */
package com.keylesspalace.tusky.components.notifications
import android.content.Context
import androidx.work.ListenableWorker
import androidx.work.Worker
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import javax.inject.Inject
class NotificationWorker(
context: Context,
params: WorkerParameters,
private val notificationsFetcher: NotificationFetcher
) : Worker(context, params) {
override fun doWork(): Result {
notificationsFetcher.fetchAndShow()
return Result.success()
}
}
class NotificationWorkerFactory @Inject constructor(
private val notificationsFetcher: NotificationFetcher
) : WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters
): ListenableWorker? {
if (workerClassName == NotificationWorker::class.java.name) {
return NotificationWorker(appContext, workerParameters, notificationsFetcher)
}
return null
}
}

View File

@ -41,6 +41,8 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import at.connyduck.sparkbutton.helpers.Utils
@ -105,7 +107,7 @@ class NotificationsFragment :
adapter = NotificationsPagingAdapter(
notificationDiffCallback,
accountId = accountManager.activeAccount!!.accountId,
accountId = viewModel.account.accountId,
statusActionListener = this,
notificationActionListener = this,
accountActionListener = this,
@ -193,6 +195,19 @@ class NotificationsFragment :
}
}
}
@Suppress("SyntheticAccessor")
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
newState != SCROLL_STATE_IDLE && return
// Save the ID of the first notification visible in the list, so the user's
// reading position is always restorable.
layoutManager.findFirstVisibleItemPosition().takeIf { it != NO_POSITION }?.let { position ->
adapter.snapshot().getOrNull(position)?.id?.let { id ->
viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id))
}
}
}
})
binding.recyclerView.adapter = adapter.withLoadStateHeaderAndFooter(
@ -253,7 +268,7 @@ class NotificationsFragment :
Log.d(TAG, error.toString())
val message = getString(
error.message,
error.exception.localizedMessage
error.throwable.localizedMessage
?: getString(R.string.ui_error_unknown)
)
val snackbar = Snackbar.make(
@ -439,6 +454,10 @@ class NotificationsFragment :
onRefresh()
true
}
R.id.load_newest -> {
viewModel.accept(InfallibleUiAction.LoadNewest)
true
}
else -> false
}
}
@ -446,7 +465,7 @@ class NotificationsFragment :
override fun onRefresh() {
binding.progressBar.isVisible = false
adapter.refresh()
NotificationHelper.clearNotificationsForActiveAccount(requireContext(), accountManager)
NotificationHelper.clearNotificationsForAccount(requireContext(), viewModel.account)
}
override fun onPause() {
@ -463,7 +482,7 @@ class NotificationsFragment :
override fun onResume() {
super.onResume()
NotificationHelper.clearNotificationsForActiveAccount(requireContext(), accountManager)
NotificationHelper.clearNotificationsForAccount(requireContext(), viewModel.account)
}
override fun onReply(position: Int) {
@ -592,7 +611,7 @@ class NotificationsFragment :
override fun onViewReport(reportId: String) {
requireContext().openLink(
"https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId"
"https://${viewModel.account.domain}/admin/reports/$reportId"
)
}
@ -608,7 +627,7 @@ class NotificationsFragment :
}
companion object {
private const val TAG = "NotificationF"
private const val TAG = "NotificationsFragment"
fun newInstance() = NotificationsFragment()
private val notificationDiffCallback: DiffUtil.ItemCallback<NotificationViewData> =

View File

@ -117,10 +117,6 @@ class NotificationsPagingAdapter(
)
}
init {
stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
}
override fun getItemViewType(position: Int): Int {
return NotificationViewKind.from(getItem(position)?.type).ordinal
}
@ -147,13 +143,15 @@ class NotificationsPagingAdapter(
NotificationViewKind.FOLLOW -> {
FollowViewHolder(
ItemFollowBinding.inflate(inflater, parent, false),
notificationActionListener
notificationActionListener,
statusActionListener
)
}
NotificationViewKind.FOLLOW_REQUEST -> {
FollowRequestViewHolder(
ItemFollowRequestBinding.inflate(inflater, parent, false),
accountActionListener,
statusActionListener,
showHeader = true
)
}

View File

@ -31,7 +31,21 @@ import retrofit2.Response
import javax.inject.Inject
/** Models next/prev links from the "Links" header in an API response */
data class Links(val next: String?, val prev: String?)
data class Links(val next: String?, val prev: String?) {
companion object {
fun from(linkHeader: String?): Links {
val links = HttpHeaderLink.parse(linkHeader)
return Links(
next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter(
"max_id"
),
prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter(
"min_id"
)
)
}
}
}
/** [PagingSource] for Mastodon Notifications, identified by the Notification ID */
class NotificationsPagingSource @Inject constructor(
@ -79,7 +93,7 @@ class NotificationsPagingSource @Inject constructor(
return LoadResult.Error(Throwable("HTTP $code: $msg"))
}
val links = getPageLinks(response.headers()["link"])
val links = Links.from(response.headers()["link"])
return LoadResult.Page(
data = response.body()!!,
nextKey = links.next,
@ -97,9 +111,10 @@ class NotificationsPagingSource @Inject constructor(
* - If there is no key, a page of the most recent notifications is returned
* - If the notification exists, and is not filtered, a page of notifications is returned
* - If the notification does not exist, or is filtered, the page of notifications immediately
* before is returned
* before is returned (if non-empty)
* - If there is no page of notifications immediately before then the page immediately after
* is returned
* is returned (if non-empty)
* - Finally, fall back to the most recent notifications
*/
private suspend fun getInitialPage(params: LoadParams<String>): Response<List<Notification>> = coroutineScope {
// If the key is null this is straightforward, just return the most recent notifications.
@ -163,36 +178,35 @@ class NotificationsPagingSource @Inject constructor(
}
// The user's last read notification was missing or is filtered. Use the page of
// notifications chronologically older than their desired notification.
deferredNotificationPage.await().apply {
if (this.isSuccessful) return@coroutineScope this
// notifications chronologically older than their desired notification. This page must
// *not* be empty (as noted earlier, if it is, paging stops).
deferredNotificationPage.await().let { response ->
if (response.isSuccessful) {
if (!response.body().isNullOrEmpty()) return@coroutineScope response
}
}
// There were no notifications older than the user's desired notification. Return the page
// of notifications immediately newer than their desired notification.
// of notifications immediately newer than their desired notification. This page must
// *not* be empty (as noted earlier, if it is, paging stops).
mastodonApi.notifications(minId = key, limit = params.loadSize, excludes = notificationFilter).let { response ->
if (response.isSuccessful) {
if (!response.body().isNullOrEmpty()) return@coroutineScope response
}
}
// Everything failed -- fallback to fetching the most recent notifications
return@coroutineScope mastodonApi.notifications(
minId = key,
limit = params.loadSize,
excludes = notificationFilter
)
}
private fun getPageLinks(linkHeader: String?): Links {
val links = HttpHeaderLink.parse(linkHeader)
return Links(
next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter(
"max_id"
),
prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter(
"min_id"
)
)
}
override fun getRefreshKey(state: PagingState<String, Notification>): String? {
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey ?: anchorPage?.nextKey
val id = state.closestItemToPosition(anchorPosition)?.id
Log.d(TAG, " getRefreshKey returning $id")
return id
}
}

View File

@ -25,6 +25,7 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
import at.connyduck.calladapter.networkresult.getOrThrow
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.EventHub
@ -40,11 +41,13 @@ import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.deserialize
import com.keylesspalace.tusky.util.serialize
import com.keylesspalace.tusky.util.throttleFirst
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@ -52,19 +55,22 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import retrofit2.HttpException
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.ExperimentalTime
data class UiState(
/** Filtered notification types */
@ -118,6 +124,12 @@ sealed class InfallibleUiAction : UiAction() {
* can do.
*/
data class SaveVisibleId(val visibleId: String) : InfallibleUiAction()
/** Ignore the saved reading position, load the page with the newest items */
// Resets the account's `lastNotificationId`, which can't fail, which is why this is
// infallible. Reloading the data may fail, but that's handled by the paging system /
// adapter refresh logic.
object LoadNewest : InfallibleUiAction()
}
/** Actions the user can trigger on an individual notification. These may fail. */
@ -218,7 +230,7 @@ sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess() {
/** Errors from fallible view model actions that the UI will need to show */
sealed class UiError(
/** The exception associated with the error */
open val exception: Exception,
open val throwable: Throwable,
/** String resource with an error message to show the user */
@StringRes val message: Int,
@ -226,55 +238,55 @@ sealed class UiError(
/** The action that failed. Can be resent to retry the action */
open val action: UiAction? = null
) {
data class ClearNotifications(override val exception: Exception) : UiError(
exception,
data class ClearNotifications(override val throwable: Throwable) : UiError(
throwable,
R.string.ui_error_clear_notifications
)
data class Bookmark(
override val exception: Exception,
override val throwable: Throwable,
override val action: StatusAction.Bookmark
) : UiError(exception, R.string.ui_error_bookmark, action)
) : UiError(throwable, R.string.ui_error_bookmark, action)
data class Favourite(
override val exception: Exception,
override val throwable: Throwable,
override val action: StatusAction.Favourite
) : UiError(exception, R.string.ui_error_favourite, action)
) : UiError(throwable, R.string.ui_error_favourite, action)
data class Reblog(
override val exception: Exception,
override val throwable: Throwable,
override val action: StatusAction.Reblog
) : UiError(exception, R.string.ui_error_reblog, action)
) : UiError(throwable, R.string.ui_error_reblog, action)
data class VoteInPoll(
override val exception: Exception,
override val throwable: Throwable,
override val action: StatusAction.VoteInPoll
) : UiError(exception, R.string.ui_error_vote, action)
) : UiError(throwable, R.string.ui_error_vote, action)
data class AcceptFollowRequest(
override val exception: Exception,
override val throwable: Throwable,
override val action: NotificationAction.AcceptFollowRequest
) : UiError(exception, R.string.ui_error_accept_follow_request, action)
) : UiError(throwable, R.string.ui_error_accept_follow_request, action)
data class RejectFollowRequest(
override val exception: Exception,
override val throwable: Throwable,
override val action: NotificationAction.RejectFollowRequest
) : UiError(exception, R.string.ui_error_reject_follow_request, action)
) : UiError(throwable, R.string.ui_error_reject_follow_request, action)
companion object {
fun make(exception: Exception, action: FallibleUiAction) = when (action) {
is StatusAction.Bookmark -> Bookmark(exception, action)
is StatusAction.Favourite -> Favourite(exception, action)
is StatusAction.Reblog -> Reblog(exception, action)
is StatusAction.VoteInPoll -> VoteInPoll(exception, action)
is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(exception, action)
is NotificationAction.RejectFollowRequest -> RejectFollowRequest(exception, action)
FallibleUiAction.ClearNotifications -> ClearNotifications(exception)
fun make(throwable: Throwable, action: FallibleUiAction) = when (action) {
is StatusAction.Bookmark -> Bookmark(throwable, action)
is StatusAction.Favourite -> Favourite(throwable, action)
is StatusAction.Reblog -> Reblog(throwable, action)
is StatusAction.VoteInPoll -> VoteInPoll(throwable, action)
is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(throwable, action)
is NotificationAction.RejectFollowRequest -> RejectFollowRequest(throwable, action)
FallibleUiAction.ClearNotifications -> ClearNotifications(throwable)
}
}
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class, ExperimentalTime::class)
class NotificationsViewModel @Inject constructor(
private val repository: NotificationsRepository,
private val preferences: SharedPreferences,
@ -282,6 +294,8 @@ class NotificationsViewModel @Inject constructor(
private val timelineCases: TimelineCases,
private val eventHub: EventHub
) : ViewModel() {
/** The account to display notifications for */
val account = accountManager.activeAccount!!
val uiState: StateFlow<UiState>
@ -293,15 +307,25 @@ class NotificationsViewModel @Inject constructor(
/** Flow of user actions received from the UI */
private val uiAction = MutableSharedFlow<UiAction>()
/** Flow that can be used to trigger a full reload */
private val reload = MutableStateFlow(0)
/** Flow of successful action results */
// Note: These are a SharedFlow instead of a StateFlow because success or error state does not
// need to be retained. A message is shown once to a user and then dismissed. Re-collecting the
// flow (e.g., after a device orientation change) should not re-show the most recent success or
// error message, as it will be confusing to the user.
// Note: This is a SharedFlow instead of a StateFlow because success state does not need to be
// retained. A message is shown once to a user and then dismissed. Re-collecting the flow
// (e.g., after a device orientation change) should not re-show the most recent success
// message, as it will be confusing to the user.
val uiSuccess = MutableSharedFlow<UiSuccess>()
/** Flow of transient errors for the UI to present */
val uiError = MutableSharedFlow<UiError>()
/** Channel for error results */
// Errors are sent to a channel to ensure that any errors that occur *before* there are any
// subscribers are retained. If this was a SharedFlow any errors would be dropped, and if it
// was a StateFlow any errors would be retained, and there would need to be an explicit
// mechanism to dismiss them.
private val _uiErrorChannel = Channel<UiError>()
/** Expose UI errors as a flow */
val uiError = _uiErrorChannel.receiveAsFlow()
/** Accept UI actions in to actionStateFlow */
val accept: (UiAction) -> Unit = { action ->
@ -316,31 +340,39 @@ class NotificationsViewModel @Inject constructor(
// Save each change back to the active account
.onEach { action ->
Log.d(TAG, "notificationFilter: $action")
accountManager.activeAccount?.let { account ->
account.notificationsFilter = serialize(action.filter)
accountManager.saveAccount(account)
}
account.notificationsFilter = serialize(action.filter)
accountManager.saveAccount(account)
}
// Load the initial filter from the active account
.onStart {
emit(
InfallibleUiAction.ApplyFilter(
filter = deserialize(accountManager.activeAccount?.notificationsFilter)
filter = deserialize(account.notificationsFilter)
)
)
}
// Reset the last notification ID to "0" to fetch the newest notifications, and
// increment `reload` to trigger creation of a new PagingSource.
viewModelScope.launch {
uiAction
.filterIsInstance<InfallibleUiAction.LoadNewest>()
.collectLatest {
account.lastNotificationId = "0"
accountManager.saveAccount(account)
reload.getAndUpdate { it + 1 }
}
}
// Save the visible notification ID
viewModelScope.launch {
uiAction
.filterIsInstance<InfallibleUiAction.SaveVisibleId>()
.distinctUntilChanged()
.collectLatest { action ->
Log.d(TAG, "Saving visible ID: ${action.visibleId}")
accountManager.activeAccount?.let { account ->
account.lastNotificationId = action.visibleId
accountManager.saveAccount(account)
}
Log.d(TAG, "Saving visible ID: ${action.visibleId}, active account = ${account.id}")
account.lastNotificationId = action.visibleId
accountManager.saveAccount(account)
}
}
@ -351,7 +383,7 @@ class NotificationsViewModel @Inject constructor(
statusDisplayOptions = MutableStateFlow(
StatusDisplayOptions.from(
preferences,
accountManager.activeAccount!!
account
)
)
@ -363,7 +395,7 @@ class NotificationsViewModel @Inject constructor(
statusDisplayOptions.value.make(
preferences,
it.preferenceKey,
accountManager.activeAccount!!
account
)
}
.collect {
@ -380,11 +412,11 @@ class NotificationsViewModel @Inject constructor(
if (this.isSuccessful) {
repository.invalidate()
} else {
uiError.emit(UiError.make(HttpException(this), it))
_uiErrorChannel.send(UiError.make(HttpException(this), it))
}
}
} catch (e: Exception) {
ifExpected(e) { uiError.emit(UiError.make(e, it)) }
ifExpected(e) { _uiErrorChannel.send(UiError.make(e, it)) }
}
}
}
@ -392,7 +424,7 @@ class NotificationsViewModel @Inject constructor(
// Handle NotificationAction.*
viewModelScope.launch {
uiAction.filterIsInstance<NotificationAction>()
.debounce(DEBOUNCE_TIMEOUT_MS)
.throttleFirst(THROTTLE_TIMEOUT)
.collect { action ->
try {
when (action) {
@ -403,7 +435,7 @@ class NotificationsViewModel @Inject constructor(
}
uiSuccess.emit(NotificationActionSuccess.from(action))
} catch (e: Exception) {
ifExpected(e) { uiError.emit(UiError.make(e, action)) }
ifExpected(e) { _uiErrorChannel.send(UiError.make(e, action)) }
}
}
}
@ -411,7 +443,7 @@ class NotificationsViewModel @Inject constructor(
// Handle StatusAction.*
viewModelScope.launch {
uiAction.filterIsInstance<StatusAction>()
.debounce(DEBOUNCE_TIMEOUT_MS) // avoid double-taps
.throttleFirst(THROTTLE_TIMEOUT) // avoid double-taps
.collect { action ->
try {
when (action) {
@ -436,10 +468,10 @@ class NotificationsViewModel @Inject constructor(
action.poll.id,
action.choices
)
}
}.getOrThrow()
uiSuccess.emit(StatusActionSuccess.from(action))
} catch (e: Exception) {
ifExpected(e) { uiError.emit(UiError.make(e, action)) }
} catch (t: Throwable) {
_uiErrorChannel.send(UiError.make(t, action))
}
}
}
@ -455,19 +487,12 @@ class NotificationsViewModel @Inject constructor(
}
}
// The database stores "0" as the last notification ID if notifications have not been
// fetched. Convert to null to ensure a full fetch in this case
val lastNotificationId = when (val id = accountManager.activeAccount?.lastNotificationId) {
"0" -> null
else -> id
}
Log.d(TAG, "Restoring at $lastNotificationId")
pagingData = notificationFilter
// Re-fetch notifications if either of `notificationFilter` or `reload` flows have
// new items.
pagingData = combine(notificationFilter, reload) { action, _ -> action }
.flatMapLatest { action ->
getNotifications(filters = action.filter, initialKey = lastNotificationId)
}
.cachedIn(viewModelScope)
getNotifications(filters = action.filter, initialKey = getInitialKey())
}.cachedIn(viewModelScope)
uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs ->
UiState(
@ -499,6 +524,17 @@ class NotificationsViewModel @Inject constructor(
}
}
// The database stores "0" as the last notification ID if notifications have not been
// fetched. Convert to null to ensure a full fetch in this case
private fun getInitialKey(): String? {
val initialKey = when (val id = account.lastNotificationId) {
"0" -> null
else -> id
}
Log.d(TAG, "Restoring at $initialKey")
return initialKey
}
/**
* @return Flow of relevant preferences that change the UI
*/
@ -516,6 +552,6 @@ class NotificationsViewModel @Inject constructor(
companion object {
private const val TAG = "NotificationsViewModel"
private const val DEBOUNCE_TIMEOUT_MS = 500L
private val THROTTLE_TIMEOUT = 500.milliseconds
}
}

View File

@ -151,8 +151,9 @@ fun disableAllNotifications(context: Context, accountManager: AccountManager) {
private fun buildSubscriptionData(context: Context, account: AccountEntity): Map<String, Boolean> =
buildMap {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
Notification.Type.visibleTypes.forEach {
put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(account, it, context))
put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(notificationManager, account, it))
}
}

View File

@ -21,7 +21,6 @@ import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.annotation.DrawableRes
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
@ -30,7 +29,6 @@ import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.TabPreferenceActivity
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
import com.keylesspalace.tusky.components.filters.FiltersActivity
import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity
@ -42,7 +40,7 @@ import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.AccountPreferenceHandler
import com.keylesspalace.tusky.settings.AccountPreferenceDataStore
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.settings.listPreference
import com.keylesspalace.tusky.settings.makePreferenceScreen
@ -58,7 +56,6 @@ import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeRes
import kotlinx.coroutines.launch
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
@ -74,6 +71,9 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var accountPreferenceDataStore: AccountPreferenceDataStore
private val iconSize by unsafeLazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) }
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
@ -245,27 +245,26 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
preferenceCategory(R.string.pref_title_timelines) {
// TODO having no activeAccount in this fragment does not really make sense, enforce it?
// All other locations here make it optional, however.
val accountPreferenceHandler = AccountPreferenceHandler(accountManager.activeAccount!!, accountManager, ::dispatchEvent)
switchPreference {
key = PrefKeys.MEDIA_PREVIEW_ENABLED
setTitle(R.string.pref_title_show_media_preview)
isSingleLineTitle = false
preferenceDataStore = accountPreferenceHandler
preferenceDataStore = accountPreferenceDataStore
}
switchPreference {
key = PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA
setTitle(R.string.pref_title_alway_show_sensitive_media)
isSingleLineTitle = false
preferenceDataStore = accountPreferenceHandler
preferenceDataStore = accountPreferenceDataStore
}
switchPreference {
key = PrefKeys.ALWAYS_OPEN_SPOILER
setTitle(R.string.pref_title_alway_open_spoiler)
isSingleLineTitle = false
preferenceDataStore = accountPreferenceHandler
preferenceDataStore = accountPreferenceDataStore
}
}
}
@ -353,12 +352,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
}
private fun dispatchEvent(event: PreferenceChangedEvent) {
lifecycleScope.launch {
eventHub.dispatch(event)
}
}
companion object {
fun newInstance() = AccountPreferencesFragment()
}

View File

@ -95,7 +95,9 @@ class PreferencesActivity :
}
onBackPressedDispatcher.addCallback(this, restartActivitiesOnBackPressedCallback)
restartActivitiesOnBackPressedCallback.isEnabled = savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false
restartActivitiesOnBackPressedCallback.isEnabled = intent.extras?.getBoolean(
EXTRA_RESTART_ON_BACK
) ?: savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false
}
override fun onPreferenceStartFragment(
@ -151,6 +153,10 @@ class PreferencesActivity :
restartActivitiesOnBackPressedCallback.isEnabled = true
this.restartCurrentActivity()
}
PrefKeys.UI_TEXT_SCALE_RATIO -> {
restartActivitiesOnBackPressedCallback.isEnabled = true
this.restartCurrentActivity()
}
"statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars", "useBlurhash",
"showSelfUsername", "showCardsInTimelines", "confirmReblogs", "confirmFavourites",
"enableSwipeForTabs", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR, PrefKeys.SHOW_STATS_INLINE -> {
@ -175,7 +181,8 @@ class PreferencesActivity :
override fun androidInjector() = androidInjector
companion object {
@Suppress("unused")
private const val TAG = "PreferencesActivity"
const val GENERAL_PREFERENCES = 0
const val ACCOUNT_PREFERENCES = 1
const val NOTIFICATION_PREFERENCES = 2

View File

@ -29,6 +29,7 @@ import com.keylesspalace.tusky.settings.listPreference
import com.keylesspalace.tusky.settings.makePreferenceScreen
import com.keylesspalace.tusky.settings.preference
import com.keylesspalace.tusky.settings.preferenceCategory
import com.keylesspalace.tusky.settings.sliderPreference
import com.keylesspalace.tusky.settings.switchPreference
import com.keylesspalace.tusky.util.LocaleManager
import com.keylesspalace.tusky.util.deserialize
@ -99,6 +100,19 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
preferenceDataStore = localeManager
}
sliderPreference {
key = PrefKeys.UI_TEXT_SCALE_RATIO
setDefaultValue(100F)
valueTo = 150F
valueFrom = 50F
stepSize = 5F
setTitle(R.string.pref_ui_text_size)
format = "%.0f%%"
decrementIcon = makeIcon(GoogleMaterial.Icon.gmd_zoom_out)
incrementIcon = makeIcon(GoogleMaterial.Icon.gmd_zoom_in)
icon = makeIcon(GoogleMaterial.Icon.gmd_format_size)
}
listPreference {
setDefaultValue("medium")
setEntries(R.array.post_text_size_names)

View File

@ -71,6 +71,11 @@ class ReportActivity : BottomSheetActivity(), HasAndroidInjector {
private fun initViewPager() {
binding.wizard.isUserInputEnabled = false
// Odd workaround for text field losing focus on first focus
// (unfixed old bug: https://github.com/material-components/material-components-android/issues/500)
binding.wizard.offscreenPageLimit = 1
binding.wizard.adapter = ReportPagerAdapter(this)
}

View File

@ -47,7 +47,6 @@ import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class ScheduledStatusActivity :
@ -102,15 +101,7 @@ class ScheduledStatusActivity :
binding.errorMessageView.show()
val errorState = loadState.refresh as LoadState.Error
if (errorState.error is IOException) {
binding.errorMessageView.setup(R.drawable.elephant_offline, R.string.error_network) {
refreshStatuses()
}
} else {
binding.errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) {
refreshStatuses()
}
}
binding.errorMessageView.setup(errorState.error) { refreshStatuses() }
}
if (loadState.refresh != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false

View File

@ -46,11 +46,8 @@ class SearchViewModel @Inject constructor(
var currentQuery: String = ""
var activeAccount: AccountEntity?
val activeAccount: AccountEntity?
get() = accountManager.activeAccount
set(value) {
accountManager.activeAccount = value
}
val mediaPreviewEnabled = activeAccount?.mediaPreviewEnabled ?: false
val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false

View File

@ -15,15 +15,28 @@
package com.keylesspalace.tusky.components.search.fragments
import android.os.Bundle
import android.view.View
import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.settings.PrefKeys
import kotlinx.coroutines.flow.Flow
class SearchAccountsFragment : SearchFragment<TimelineAccount>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.searchRecyclerView.addItemDecoration(
DividerItemDecoration(
binding.searchRecyclerView.context,
DividerItemDecoration.VERTICAL
)
)
}
override fun createAdapter(): PagingDataAdapter<TimelineAccount, *> {
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)

View File

@ -13,7 +13,6 @@ import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@ -129,7 +128,6 @@ abstract class SearchFragment<T : Any> :
}
private fun initAdapter() {
binding.searchRecyclerView.addItemDecoration(DividerItemDecoration(binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL))
binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context)
adapter = createAdapter()
binding.searchRecyclerView.adapter = adapter

View File

@ -15,8 +15,11 @@
package com.keylesspalace.tusky.components.search.fragments
import android.os.Bundle
import android.view.View
import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DividerItemDecoration
import com.keylesspalace.tusky.components.search.adapter.SearchHashtagsAdapter
import com.keylesspalace.tusky.entity.HashTag
import kotlinx.coroutines.flow.Flow
@ -26,6 +29,16 @@ class SearchHashtagsFragment : SearchFragment<HashTag>() {
override val data: Flow<PagingData<HashTag>>
get() = viewModel.hashtagsFlow
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.searchRecyclerView.addItemDecoration(
DividerItemDecoration(
binding.searchRecyclerView.context,
DividerItemDecoration.VERTICAL
)
)
}
override fun createAdapter(): PagingDataAdapter<HashTag, *> = SearchHashtagsAdapter(this)
companion object {

View File

@ -77,10 +77,8 @@ import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@ -243,16 +241,7 @@ class TimelineFragment :
}
is LoadState.Error -> {
binding.statusView.show()
if ((loadState.refresh as LoadState.Error).error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
onRefresh()
}
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
onRefresh()
}
}
binding.statusView.setup((loadState.refresh as LoadState.Error).error) { onRefresh() }
}
is LoadState.Loading -> {
binding.progressBar.show()
@ -578,6 +567,17 @@ class TimelineFragment :
private var talkBackWasEnabled = false
override fun onPause() {
super.onPause()
(binding.recyclerView.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition()?.let { position ->
if (position != RecyclerView.NO_POSITION) {
adapter.snapshot().getOrNull(position)?.id?.let { statusId ->
viewModel.saveReadingPosition(statusId)
}
}
}
}
override fun onResume() {
super.onResume()
val a11yManager =

View File

@ -50,14 +50,11 @@ import com.keylesspalace.tusky.util.EmptyPagingSource
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import retrofit2.HttpException
import javax.inject.Inject
import kotlin.time.DurationUnit
import kotlin.time.toDuration
/**
* TimelineViewModel that caches all statuses in a local database
@ -107,16 +104,6 @@ class CachedTimelineViewModel @Inject constructor(
.flowOn(Dispatchers.Default)
.cachedIn(viewModelScope)
init {
viewModelScope.launch {
delay(5.toDuration(DurationUnit.SECONDS)) // delay so the db is not locked during initial ui refresh
accountManager.activeAccount?.id?.let { accountId ->
db.timelineDao().cleanup(accountId, MAX_STATUSES_IN_CACHE)
db.timelineDao().cleanupAccounts(accountId)
}
}
}
override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) {
// handled by CacheUpdater
}
@ -289,6 +276,14 @@ class CachedTimelineViewModel @Inject constructor(
}
}
override fun saveReadingPosition(statusId: String) {
accountManager.activeAccount?.let { account ->
Log.d(TAG, "Saving position at: $statusId")
account.lastVisibleHomeTimelineStatusId = statusId
accountManager.saveAccount(account)
}
}
override suspend fun invalidate() {
// invalidating when we don't have statuses yet can cause empty timelines because it cancels the network load
if (db.timelineDao().getStatusCount(accountManager.activeAccount!!.id) > 0) {
@ -297,6 +292,7 @@ class CachedTimelineViewModel @Inject constructor(
}
companion object {
private const val TAG = "CachedTimelineViewModel"
private const val MAX_STATUSES_IN_CACHE = 1000
}
}

View File

@ -255,6 +255,10 @@ class NetworkTimelineViewModel @Inject constructor(
}
}
override fun saveReadingPosition(statusId: String) {
/** Does nothing for non-cached timelines */
}
override suspend fun invalidate() {
currentSource?.invalidate()
}

View File

@ -182,6 +182,9 @@ abstract class TimelineViewModel(
abstract fun clearWarning(status: StatusViewData.Concrete)
/** Saves the user's reading position so it can be restored later */
abstract fun saveReadingPosition(statusId: String)
/** Triggered when currently displayed data must be reloaded. */
protected abstract suspend fun invalidate()

View File

@ -19,23 +19,19 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.commit
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.databinding.ActivityTrendingBinding
import com.keylesspalace.tusky.util.viewBinding
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
class TrendingActivity : BottomSheetActivity(), HasAndroidInjector {
class TrendingActivity : BaseActivity(), HasAndroidInjector {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
@Inject
lateinit var eventHub: EventHub
private val binding: ActivityTrendingBinding by viewBinding(ActivityTrendingBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) {
@ -44,10 +40,8 @@ class TrendingActivity : BottomSheetActivity(), HasAndroidInjector {
setSupportActionBar(binding.includedToolbar.toolbar)
val title = getString(R.string.title_public_trending_hashtags)
supportActionBar?.run {
setTitle(title)
setTitle(R.string.title_public_trending_hashtags)
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
@ -63,10 +57,6 @@ class TrendingActivity : BottomSheetActivity(), HasAndroidInjector {
override fun androidInjector() = dispatchingAndroidInjector
companion object {
const val TAG = "TrendingActivity"
@JvmStatic
fun getIntent(context: Context) =
Intent(context, TrendingActivity::class.java)
fun getIntent(context: Context) = Intent(context, TrendingActivity::class.java)
}
}

View File

@ -20,15 +20,12 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.adapter.TrendingDateViewHolder
import com.keylesspalace.tusky.adapter.TrendingTagViewHolder
import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding
import com.keylesspalace.tusky.databinding.ItemTrendingDateBinding
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.viewdata.TrendingViewData
class TrendingAdapter(
private val trendingListener: LinkListener
private val onViewTag: (String) -> Unit
) : ListAdapter<TrendingViewData, RecyclerView.ViewHolder>(TrendingDifferCallback) {
init {
@ -42,7 +39,6 @@ class TrendingAdapter(
ItemTrendingCellBinding.inflate(LayoutInflater.from(viewGroup.context))
TrendingTagViewHolder(binding)
}
else -> {
val binding =
ItemTrendingDateBinding.inflate(LayoutInflater.from(viewGroup.context))
@ -52,38 +48,15 @@ class TrendingAdapter(
}
override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
bindViewHolder(viewHolder, position, null)
}
override fun onBindViewHolder(
viewHolder: RecyclerView.ViewHolder,
position: Int,
payloads: List<*>
) {
bindViewHolder(viewHolder, position, payloads)
}
private fun bindViewHolder(
viewHolder: RecyclerView.ViewHolder,
position: Int,
payloads: List<*>?
) {
when (val header = getItem(position)) {
when (val viewData = getItem(position)) {
is TrendingViewData.Tag -> {
val maxTrendingValue = currentList
.flatMap { trendingViewData ->
trendingViewData.asTagOrNull()?.tag?.history.orEmpty()
}
.mapNotNull { it.uses.toLongOrNull() }
.maxOrNull() ?: 1
val holder = viewHolder as TrendingTagViewHolder
holder.setup(header, maxTrendingValue, trendingListener)
holder.setup(viewData, onViewTag)
}
is TrendingViewData.Header -> {
val holder = viewHolder as TrendingDateViewHolder
holder.setup(header.start, header.end)
holder.setup(viewData.start, viewData.end)
}
}
}
@ -112,14 +85,7 @@ class TrendingAdapter(
oldItem: TrendingViewData,
newItem: TrendingViewData
): Boolean {
return false
}
override fun getChangePayload(
oldItem: TrendingViewData,
newItem: TrendingViewData
): Any? {
return null
return oldItem == newItem
}
}
}

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.adapter
package com.keylesspalace.tusky.components.trending
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R

View File

@ -15,17 +15,14 @@
package com.keylesspalace.tusky.components.trending
import android.content.Context
import android.content.res.Configuration
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.accessibility.AccessibilityManager
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup
@ -33,18 +30,14 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import at.connyduck.sparkbutton.helpers.Utils
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.PostLookupFallbackBehavior
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel
import com.keylesspalace.tusky.databinding.FragmentTrendingBinding
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.interfaces.RefreshableFragment
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.util.hide
@ -56,48 +49,20 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
class TrendingFragment :
Fragment(),
Fragment(R.layout.fragment_trending),
OnRefreshListener,
LinkListener,
Injectable,
ReselectableFragment,
RefreshableFragment {
private lateinit var bottomSheetActivity: BottomSheetActivity
@Inject
lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var accountManager: AccountManager
@Inject
lateinit var eventHub: EventHub
private val viewModel: TrendingViewModel by lazy {
ViewModelProvider(this, viewModelFactory)[TrendingViewModel::class.java]
}
private val viewModel: TrendingViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(FragmentTrendingBinding::bind)
private lateinit var adapter: TrendingAdapter
override fun onAttach(context: Context) {
super.onAttach(context)
bottomSheetActivity = if (context is BottomSheetActivity) {
context
} else {
throw IllegalStateException("Fragment must be attached to a BottomSheetActivity!")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = TrendingAdapter(
this
)
}
private val adapter = TrendingAdapter(::onViewTag)
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
@ -106,14 +71,6 @@ class TrendingFragment :
setupLayoutManager(columnCount)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_trending, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
setupSwipeRefreshLayout()
setupRecyclerView()
@ -175,25 +132,19 @@ class TrendingFragment :
}
override fun onRefresh() {
viewModel.invalidate()
viewModel.invalidate(true)
}
override fun onViewUrl(url: String) {
bottomSheetActivity.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER)
}
override fun onViewTag(tag: String) {
bottomSheetActivity.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
}
override fun onViewAccount(id: String) {
bottomSheetActivity.viewAccount(id)
fun onViewTag(tag: String) {
(requireActivity() as BaseActivity).startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
}
private fun processViewState(uiState: TrendingViewModel.TrendingUiState) {
Log.d(TAG, uiState.loadingState.name)
when (uiState.loadingState) {
TrendingViewModel.LoadingState.INITIAL -> clearLoadingState()
TrendingViewModel.LoadingState.LOADING -> applyLoadingState()
TrendingViewModel.LoadingState.REFRESHING -> applyRefreshingState()
TrendingViewModel.LoadingState.LOADED -> applyLoadedState(uiState.trendingViewData)
TrendingViewModel.LoadingState.ERROR_NETWORK -> networkError()
TrendingViewModel.LoadingState.ERROR_OTHER -> otherError()
@ -203,8 +154,9 @@ class TrendingFragment :
private fun applyLoadedState(viewData: List<TrendingViewData>) {
clearLoadingState()
adapter.submitList(viewData)
if (viewData.isEmpty()) {
adapter.submitList(emptyList())
binding.recyclerView.hide()
binding.messageView.show()
binding.messageView.setup(
@ -213,16 +165,16 @@ class TrendingFragment :
null
)
} else {
val viewDataWithDates = listOf(viewData.first().asHeaderOrNull()) + viewData
adapter.submitList(viewDataWithDates)
binding.recyclerView.show()
binding.messageView.hide()
}
binding.progressBar.hide()
}
private fun applyRefreshingState() {
binding.swipeRefreshLayout.isRefreshing = true
}
private fun applyLoadingState() {
binding.recyclerView.hide()
binding.messageView.hide()
@ -297,8 +249,6 @@ class TrendingFragment :
companion object {
private const val TAG = "TrendingFragment"
fun newInstance(): TrendingFragment {
return TrendingFragment()
}
fun newInstance() = TrendingFragment()
}
}

View File

@ -0,0 +1,57 @@
/* Copyright 2023 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.trending
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding
import com.keylesspalace.tusky.util.formatNumber
import com.keylesspalace.tusky.viewdata.TrendingViewData
class TrendingTagViewHolder(
private val binding: ItemTrendingCellBinding
) : RecyclerView.ViewHolder(binding.root) {
fun setup(
tagViewData: TrendingViewData.Tag,
onViewTag: (String) -> Unit
) {
binding.tag.text = binding.root.context.getString(R.string.title_tag, tagViewData.name)
binding.graph.maxTrendingValue = tagViewData.maxTrendingValue
binding.graph.primaryLineData = tagViewData.usage
binding.graph.secondaryLineData = tagViewData.accounts
binding.totalUsage.text = formatNumber(tagViewData.usage.sum(), 1000)
val totalAccounts = tagViewData.accounts.sum()
binding.totalAccounts.text = formatNumber(totalAccounts, 1000)
binding.currentUsage.text = tagViewData.usage.last().toString()
binding.currentAccounts.text = tagViewData.usage.last().toString()
itemView.setOnClickListener {
onViewTag(tagViewData.name)
}
itemView.contentDescription =
itemView.context.getString(
R.string.accessibility_talking_about_tag,
totalAccounts,
tagViewData.name
)
}
}

View File

@ -15,11 +15,15 @@
package com.keylesspalace.tusky.components.trending.viewmodel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.end
import com.keylesspalace.tusky.entity.start
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.TrendingViewData
@ -28,7 +32,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
import okio.IOException
import java.io.IOException
import javax.inject.Inject
class TrendingViewModel @Inject constructor(
@ -36,7 +40,7 @@ class TrendingViewModel @Inject constructor(
private val eventHub: EventHub
) : ViewModel() {
enum class LoadingState {
INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER
INITIAL, LOADING, REFRESHING, LOADED, ERROR_NETWORK, ERROR_OTHER
}
data class TrendingUiState(
@ -67,37 +71,43 @@ class TrendingViewModel @Inject constructor(
*
* A tag is excluded if it is filtered by the user on their home timeline.
*/
fun invalidate() = viewModelScope.launch {
_uiState.value = TrendingUiState(emptyList(), LoadingState.LOADING)
try {
val deferredFilters = async { mastodonApi.getFilters() }
val response = mastodonApi.trendingTags()
if (!response.isSuccessful) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK)
return@launch
}
val homeFilters = deferredFilters.await().getOrNull()?.filter {
it.context.contains(Filter.Kind.HOME.kind)
}
val tags = response.body()!!
.filter {
homeFilters?.none { filter ->
filter.keywords.any { keyword -> keyword.keyword.equals(it.name, ignoreCase = true) }
} ?: false
}
.sortedBy { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
.map { it.toViewData() }
.asReversed()
_uiState.value = TrendingUiState(tags, LoadingState.LOADED)
} catch (e: IOException) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK)
} catch (e: Exception) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_OTHER)
fun invalidate(refresh: Boolean = false) = viewModelScope.launch {
if (refresh) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.REFRESHING)
} else {
_uiState.value = TrendingUiState(emptyList(), LoadingState.LOADING)
}
val deferredFilters = async { mastodonApi.getFilters() }
mastodonApi.trendingTags().fold(
{ tagResponse ->
val homeFilters = deferredFilters.await().getOrNull()?.filter { filter ->
filter.context.contains(Filter.Kind.HOME.kind)
}
val tags = tagResponse
.filter { tag ->
homeFilters?.none { filter ->
filter.keywords.any { keyword -> keyword.keyword.equals(tag.name, ignoreCase = true) }
} ?: false
}
.sortedByDescending { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
.toViewData()
val firstTag = tagResponse.first()
val header = TrendingViewData.Header(firstTag.start(), firstTag.end())
_uiState.value = TrendingUiState(listOf(header) + tags, LoadingState.LOADED)
},
{ error ->
Log.w(TAG, "failed loading trending tags", error)
if (error is IOException) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK)
} else {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_OTHER)
}
}
)
}
companion object {

View File

@ -60,7 +60,6 @@ import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class ViewThreadFragment :
@ -201,21 +200,7 @@ class ViewThreadFragment :
binding.recyclerView.hide()
binding.statusView.show()
if (uiState.throwable is IOException) {
binding.statusView.setup(
R.drawable.elephant_offline,
R.string.error_network
) {
viewModel.retry(thisThreadsStatusId)
}
} else {
binding.statusView.setup(
R.drawable.elephant_error,
R.string.error_generic
) {
viewModel.retry(thisThreadsStatusId)
}
}
binding.statusView.setup(uiState.throwable) { viewModel.retry(thisThreadsStatusId) }
}
is ThreadUiState.Success -> {
if (uiState.statusViewData.none { viewData -> viewData.isDetailed }) {
@ -416,13 +401,14 @@ class ViewThreadFragment :
}
public override fun removeItem(position: Int) {
val status = adapter.currentList[position]
if (status.isDetailed) {
// the main status we are viewing is being removed, finish the activity
activity?.finish()
return
adapter.currentList.getOrNull(position)?.let { status ->
if (status.isDetailed) {
// the main status we are viewing is being removed, finish the activity
activity?.finish()
return
}
viewModel.removeStatus(status)
}
viewModel.removeStatus(status)
}
override fun onVoteInPoll(position: Int, choices: List<Int>) {

View File

@ -1,8 +1,6 @@
package com.keylesspalace.tusky.components.viewthread.edits
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Typeface.DEFAULT_BOLD
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
@ -11,7 +9,9 @@ import android.text.Html
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.ReplacementSpan
import android.text.TextPaint
import android.text.style.CharacterStyle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@ -33,11 +33,9 @@ import com.keylesspalace.tusky.util.aspectRatios
import com.keylesspalace.tusky.util.decodeBlurHash
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewdata.toViewData
import org.xml.sax.XMLReader
@ -52,13 +50,28 @@ class ViewEditsAdapter(
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
/** Size of large text in this theme, in px */
var largeTextSizePx: Float = 0f
/** Size of medium text in this theme, in px */
var mediumTextSizePx: Float = 0f
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemStatusEditBinding> {
val binding = ItemStatusEditBinding.inflate(LayoutInflater.from(parent.context), parent, false)
binding.statusEditMediaPreview.clipToOutline = true
val typedValue = TypedValue()
val context = binding.root.context
val displayMetrics = context.resources.displayMetrics
context.theme.resolveAttribute(R.attr.status_text_large, typedValue, true)
largeTextSizePx = typedValue.getDimension(displayMetrics)
context.theme.resolveAttribute(R.attr.status_text_medium, typedValue, true)
mediumTextSizePx = typedValue.getDimension(displayMetrics)
return BindingHolder(binding)
}
@ -69,24 +82,26 @@ class ViewEditsAdapter(
val context = binding.root.context
val avatarRadius: Int = context.resources
.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(edit.account.avatar, binding.statusEditAvatar, avatarRadius, animateAvatars)
val infoStringRes = if (position == edits.size - 1) {
val infoStringRes = if (position == edits.lastIndex) {
R.string.status_created_info
} else {
R.string.status_edit_info
}
// Show the most recent version of the status using large text to make it clearer for
// the user, and for similarity with thread view.
val variableTextSize = if (position == edits.lastIndex) {
mediumTextSizePx
} else {
largeTextSizePx
}
binding.statusEditContentWarningDescription.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize)
binding.statusEditContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize)
binding.statusEditMediaSensitivity.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize)
val timestamp = absoluteTimeFormatter.format(edit.createdAt, false)
binding.statusEditInfo.text = context.getString(
infoStringRes,
edit.account.name.unicodeWrap(),
timestamp
).emojify(edit.account.emojis, binding.statusEditInfo, animateEmojis)
binding.statusEditInfo.text = context.getString(infoStringRes, timestamp)
if (edit.spoilerText.isEmpty()) {
binding.statusEditContentWarningDescription.hide()
@ -198,6 +213,11 @@ class ViewEditsAdapter(
}
override fun getItemCount() = edits.size
companion object {
private const val VIEW_TYPE_EDITS_NEWEST = 0
private const val VIEW_TYPE_EDITS = 1
}
}
/**
@ -266,98 +286,31 @@ class TuskyTagHandler(val context: Context) : Html.TagHandler {
}
}
/**
* A span that draws text with additional padding at the start/end of the text. The padding
* is the width of [separator].
*
* Note: The separator string is not included in the final text, so it will not be included
* if the user cuts or copies the text.
*/
open class LRPaddedSpan(val separator: String = " ") : ReplacementSpan() {
/** The width of the separator string, used as padding */
var paddingWidth = 0f
/** Measured width of the span */
var spanWidth = 0f
override fun getSize(
paint: Paint,
text: CharSequence?,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?
): Int {
paddingWidth = paint.measureText(separator, 0, separator.length)
spanWidth = (paddingWidth * 2) + paint.measureText(text, start, end)
return spanWidth.toInt()
}
override fun draw(
canvas: Canvas,
text: CharSequence?,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
canvas.drawText(text?.subSequence(start, end).toString(), x + paddingWidth, y.toFloat(), paint)
}
}
/** Span that signifies deleted text */
class DeletedTextSpan(context: Context) : LRPaddedSpan() {
private val bgPaint = Paint()
val radius: Float
class DeletedTextSpan(context: Context) : CharacterStyle() {
private var bgColor: Int
init {
bgPaint.color = context.getColor(R.color.view_edits_background_delete)
radius = context.resources.getDimension(R.dimen.lrPaddedSpanRadius)
bgColor = context.getColor(R.color.view_edits_background_delete)
}
override fun draw(
canvas: Canvas,
text: CharSequence?,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
canvas.drawRoundRect(x, top.toFloat(), x + spanWidth, bottom.toFloat(), radius, radius, bgPaint)
paint.isStrikeThruText = true
super.draw(canvas, text, start, end, x, top, y, bottom, paint)
override fun updateDrawState(tp: TextPaint) {
tp.bgColor = bgColor
tp.isStrikeThruText = true
}
}
/** Span that signifies inserted text */
class InsertedTextSpan(context: Context) : LRPaddedSpan() {
val bgPaint = Paint()
val radius: Float
class InsertedTextSpan(context: Context) : CharacterStyle() {
private var bgColor: Int
init {
bgPaint.color = context.getColor(R.color.view_edits_background_insert)
radius = context.resources.getDimension(R.dimen.lrPaddedSpanRadius)
bgColor = context.getColor(R.color.view_edits_background_insert)
}
override fun draw(
canvas: Canvas,
text: CharSequence?,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
canvas.drawRoundRect(x, top.toFloat(), x + spanWidth, bottom.toFloat(), radius, radius, bgPaint)
paint.typeface = DEFAULT_BOLD
super.draw(canvas, text, start, end, x, top, y, bottom, paint)
override fun updateDrawState(tp: TextPaint) {
tp.bgColor = bgColor
tp.typeface = DEFAULT_BOLD
}
}

View File

@ -37,24 +37,26 @@ import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding
import com.keylesspalace.tusky.databinding.FragmentViewEditsBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.util.viewBinding
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class ViewEditsFragment :
Fragment(R.layout.fragment_view_thread),
Fragment(R.layout.fragment_view_edits),
LinkListener,
OnRefreshListener,
MenuProvider,
@ -65,7 +67,7 @@ class ViewEditsFragment :
private val viewModel: ViewEditsViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(FragmentViewThreadBinding::bind)
private val binding by viewBinding(FragmentViewEditsBinding::bind)
private lateinit var statusId: String
@ -88,6 +90,7 @@ class ViewEditsFragment :
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true)
val avatarRadius: Int = requireContext().resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState.collect { uiState ->
@ -107,13 +110,17 @@ class ViewEditsFragment :
binding.statusView.show()
binding.initialProgressBar.hide()
if (uiState.throwable is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
viewModel.loadEdits(statusId, force = true)
when (uiState.throwable) {
is ViewEditsViewModel.MissingEditsException -> {
binding.statusView.setup(
R.drawable.elephant_friend_empty,
R.string.error_missing_edits
)
}
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
viewModel.loadEdits(statusId, force = true)
else -> {
binding.statusView.setup(uiState.throwable) {
viewModel.loadEdits(statusId, force = true)
}
}
}
}
@ -130,6 +137,15 @@ class ViewEditsFragment :
useBlurhash = useBlurhash,
listener = this@ViewEditsFragment
)
// Focus on the most recent version
(binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPosition(0)
val account = uiState.edits.first().account
loadAvatar(account.avatar, binding.statusAvatar, avatarRadius, animateAvatars)
binding.statusDisplayName.text = account.name.unicodeWrap().emojify(account.emojis, binding.statusDisplayName, animateEmojis)
binding.statusUsername.text = account.username
}
}
}

View File

@ -17,7 +17,7 @@ package com.keylesspalace.tusky.components.viewthread.edits
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.getOrElse
import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.DELETED_TEXT_EL
import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.INSERTED_TEXT_EL
import com.keylesspalace.tusky.entity.StatusEdit
@ -48,6 +48,9 @@ class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : Vie
private val _uiState: MutableStateFlow<EditsUiState> = MutableStateFlow(EditsUiState.Initial)
val uiState: StateFlow<EditsUiState> = _uiState.asStateFlow()
/** The API call to fetch edit history returned less than two items */
object MissingEditsException : Exception()
fun loadEdits(statusId: String, force: Boolean = false, refreshing: Boolean = false) {
if (!force && _uiState.value !is EditsUiState.Initial) return
@ -58,66 +61,68 @@ class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : Vie
}
viewModelScope.launch {
api.statusEdits(statusId).fold(
{ edits ->
// Diff each status' content against the previous version, producing new
// content with additional `ins` or `del` elements marking inserted or
// deleted content.
//
// This can be CPU intensive depending on the number of edits and the size
// of each, so don't run this on Dispatchers.Main.
viewModelScope.launch(Dispatchers.Default) {
val sortedEdits = edits.sortedBy { it.createdAt }
.reversed()
.toMutableList()
val edits = api.statusEdits(statusId).getOrElse {
_uiState.value = EditsUiState.Error(it)
return@launch
}
SAXLoader.setXMLReaderClass("org.xmlpull.v1.sax2.Driver")
val loader = SAXLoader()
loader.config = DiffConfig(
false,
WhiteSpaceProcessing.PRESERVE,
TextGranularity.SPACE_WORD
// `edits` might have fewer than the minimum number of entries because of
// https://github.com/mastodon/mastodon/issues/25398.
if (edits.size < 2) {
_uiState.value = EditsUiState.Error(MissingEditsException)
return@launch
}
// Diff each status' content against the previous version, producing new
// content with additional `ins` or `del` elements marking inserted or
// deleted content.
//
// This can be CPU intensive depending on the number of edits and the size
// of each, so don't run this on Dispatchers.Main.
viewModelScope.launch(Dispatchers.Default) {
val sortedEdits = edits.sortedBy { it.createdAt }
.reversed()
.toMutableList()
SAXLoader.setXMLReaderClass("org.xmlpull.v1.sax2.Driver")
val loader = SAXLoader()
loader.config = DiffConfig(
false,
WhiteSpaceProcessing.PRESERVE,
TextGranularity.SPACE_WORD
)
val processor = OptimisticXMLProcessor()
processor.setCoalesce(true)
val output = HtmlDiffOutput()
try {
// The XML processor expects `br` to be closed
var currentContent =
loader.load(sortedEdits[0].content.replace("<br>", "<br/>"))
var previousContent =
loader.load(sortedEdits[1].content.replace("<br>", "<br/>"))
for (i in 1 until sortedEdits.size) {
processor.diff(previousContent, currentContent, output)
sortedEdits[i - 1] = sortedEdits[i - 1].copy(
content = output.xml.toString()
)
val processor = OptimisticXMLProcessor()
processor.setCoalesce(true)
val output = HtmlDiffOutput()
try {
// The XML processor expects `br` to be closed
var currentContent =
loader.load(sortedEdits[0].content.replace("<br>", "<br/>"))
var previousContent =
loader.load(sortedEdits[1].content.replace("<br>", "<br/>"))
for (i in 1 until sortedEdits.size) {
processor.diff(previousContent, currentContent, output)
sortedEdits[i - 1] = sortedEdits[i - 1].copy(
content = output.xml.toString()
)
if (i < sortedEdits.size - 1) {
currentContent = previousContent
previousContent = loader.load(
sortedEdits[i + 1].content.replace(
"<br>",
"<br/>"
)
)
}
}
_uiState.value = EditsUiState.Success(sortedEdits)
} catch (_: LoadingException) {
// Something failed parsing the XML from the server. Rather than
// show an error just return the sorted edits so the user can at
// least visually scan the differences.
_uiState.value = EditsUiState.Success(sortedEdits)
if (i < sortedEdits.size - 1) {
currentContent = previousContent
previousContent = loader.load(
sortedEdits[i + 1].content.replace("<br>", "<br/>")
)
}
}
},
{ throwable ->
_uiState.value = EditsUiState.Error(throwable)
_uiState.value = EditsUiState.Success(sortedEdits)
} catch (_: LoadingException) {
// Something failed parsing the XML from the server. Rather than
// show an error just return the sorted edits so the user can at
// least visually scan the differences.
_uiState.value = EditsUiState.Success(sortedEdits)
}
)
}
}
}

View File

@ -15,6 +15,7 @@
package com.keylesspalace.tusky.db
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@ -70,8 +71,19 @@ data class AccountEntity(
* that media previews are shown as well as downloaded.
*/
var mediaPreviewEnabled: Boolean = true,
/**
* ID of the last notification the user read on the Notification, list, and should be restored
* to view when the user returns to the list.
*
* May not be the ID of the most recent notification if the user has scrolled down the list.
*/
var lastNotificationId: String = "0",
var activeNotifications: String = "[]",
/**
* ID of the most recent Mastodon notification that Tusky has fetched to show as an
* Android notification.
*/
@ColumnInfo(defaultValue = "0")
var notificationMarkerId: String = "0",
var emojis: List<Emoji> = emptyList(),
var tabPreferences: List<TabData> = defaultTabs(),
var notificationsFilter: String = "[\"follow_request\"]",
@ -82,7 +94,13 @@ data class AccountEntity(
var pushPubKey: String = "",
var pushPrivKey: String = "",
var pushAuth: String = "",
var pushServerKey: String = ""
var pushServerKey: String = "",
/**
* ID of the status at the top of the visible list in the home timeline when the
* user navigated away.
*/
var lastVisibleHomeTimelineStatusId: String? = null
) {
val identifier: String

View File

@ -37,9 +37,11 @@ class AccountManager @Inject constructor(db: AppDatabase) {
@Volatile
var activeAccount: AccountEntity? = null
private set
var accounts: MutableList<AccountEntity> = mutableListOf()
private set
private val accountDao: AccountDao = db.accountDao()
init {
@ -52,7 +54,7 @@ class AccountManager @Inject constructor(db: AppDatabase) {
/**
* Adds a new account and makes it the active account.
* @param accessToken the access token for the new account
* @param domain the domain of the accounts Mastodon instance
* @param domain the domain of the account's Mastodon instance
* @param clientId the oauth client id used to sign in the account
* @param clientSecret the oauth client secret used to sign in the account
* @param oauthScopes the oauth scopes granted to the account

View File

@ -16,8 +16,12 @@
package com.keylesspalace.tusky.db;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.room.AutoMigration;
import androidx.room.Database;
import androidx.room.DeleteColumn;
import androidx.room.RoomDatabase;
import androidx.room.migration.AutoMigrationSpec;
import androidx.room.migration.Migration;
import androidx.sqlite.db.SupportSQLiteDatabase;
@ -29,16 +33,29 @@ import java.io.File;
/**
* DB version & declare DAO
*/
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class
}, version = 48)
@Database(
entities = {
DraftEntity.class,
AccountEntity.class,
InstanceEntity.class,
TimelineStatusEntity.class,
TimelineAccountEntity.class,
ConversationEntity.class
},
version = 51,
autoMigrations = {
@AutoMigration(from = 48, to = 49),
@AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class),
@AutoMigration(from = 50, to = 51)
}
)
public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao();
public abstract InstanceDao instanceDao();
public abstract ConversationsDao conversationDao();
public abstract TimelineDao timelineDao();
public abstract DraftDao draftDao();
@NonNull public abstract AccountDao accountDao();
@NonNull public abstract InstanceDao instanceDao();
@NonNull public abstract ConversationsDao conversationDao();
@NonNull public abstract TimelineDao timelineDao();
@NonNull public abstract DraftDao draftDao();
public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override
@ -370,7 +387,7 @@ public abstract class AppDatabase extends RoomDatabase {
private final File oldDraftDirectory;
public Migration25_26(File oldDraftDirectory) {
public Migration25_26(@Nullable File oldDraftDirectory) {
super(25, 26);
this.oldDraftDirectory = oldDraftDirectory;
}
@ -653,4 +670,7 @@ public abstract class AppDatabase extends RoomDatabase {
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `filtered` TEXT");
}
};
@DeleteColumn(tableName = "AccountEntity", columnName = "activeNotifications")
static class MIGRATION_49_50 implements AutoMigrationSpec { }
}

View File

@ -102,8 +102,8 @@ class Converters @Inject constructor(
}
@TypeConverter
fun jsonToAttachmentList(attachmentListJson: String?): ArrayList<Attachment>? {
return gson.fromJson(attachmentListJson, object : TypeToken<ArrayList<Attachment>>() {}.type)
fun jsonToAttachmentList(attachmentListJson: String?): List<Attachment>? {
return gson.fromJson(attachmentListJson, object : TypeToken<List<Attachment>>() {}.type)
}
@TypeConverter

View File

@ -29,12 +29,14 @@ import javax.inject.Singleton
@Component(
modules = [
AppModule::class,
CoroutineScopeModule::class,
NetworkModule::class,
AndroidSupportInjectionModule::class,
ActivitiesModule::class,
ServicesModule::class,
BroadcastReceiverModule::class,
ViewModelModule::class
ViewModelModule::class,
WorkerModule::class
]
)
interface AppComponent {

View File

@ -17,7 +17,6 @@
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
@ -28,9 +27,6 @@ abstract class BroadcastReceiverModule {
@ContributesAndroidInjector
abstract fun contributeSendStatusBroadcastReceiver(): SendStatusBroadcastReceiver
@ContributesAndroidInjector
abstract fun contributeNotificationClearBroadcastReceiver(): NotificationClearBroadcastReceiver
@ContributesAndroidInjector
abstract fun contributeUnifiedPushBroadcastReceiver(): UnifiedPushBroadcastReceiver

View File

@ -0,0 +1,44 @@
/*
* Copyright 2023 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.di
import dagger.Module
import dagger.Provides
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import javax.inject.Qualifier
/**
* Scope for potentially long-running tasks that should outlive the viewmodel that
* started them. For example, if the API call to bookmark a status is taking a long
* time, that call should not be cancelled because the user has navigated away from
* the viewmodel that made the call.
*
* @see https://developer.android.com/topic/architecture/data-layer#make_an_operation_live_longer_than_the_screen
*/
@Retention(AnnotationRetention.BINARY)
@Qualifier
annotation class ApplicationScope
@Module
class CoroutineScopeModule {
@ApplicationScope
@Provides
fun providesApplicationScope() = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}

View File

@ -0,0 +1,45 @@
/*
* Copyright 2023 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.di
import androidx.work.ListenableWorker
import com.keylesspalace.tusky.worker.ChildWorkerFactory
import com.keylesspalace.tusky.worker.NotificationWorker
import com.keylesspalace.tusky.worker.PruneCacheWorker
import dagger.Binds
import dagger.MapKey
import dagger.Module
import dagger.multibindings.IntoMap
import kotlin.reflect.KClass
@Retention(AnnotationRetention.RUNTIME)
@MapKey
annotation class WorkerKey(val value: KClass<out ListenableWorker>)
@Module
abstract class WorkerModule {
@Binds
@IntoMap
@WorkerKey(NotificationWorker::class)
internal abstract fun bindNotificationWorkerFactory(worker: NotificationWorker.Factory): ChildWorkerFactory
@Binds
@IntoMap
@WorkerKey(PruneCacheWorker::class)
internal abstract fun bindPruneCacheWorkerFactory(worker: PruneCacheWorker.Factory): ChildWorkerFactory
}

View File

@ -16,7 +16,6 @@
package com.keylesspalace.tusky.entity
import com.google.gson.annotations.SerializedName
import java.util.ArrayList
import java.util.Date
data class DeletedStatus(
@ -25,7 +24,7 @@ data class DeletedStatus(
@SerializedName("spoiler_text") val spoilerText: String,
val visibility: Status.Visibility,
val sensitive: Boolean,
@SerializedName("media_attachments") val attachments: ArrayList<Attachment>?,
@SerializedName("media_attachments") val attachments: List<Attachment>?,
val poll: Poll?,
@SerializedName("created_at") val createdAt: Date,
val language: String?

View File

@ -5,5 +5,5 @@ import com.google.gson.annotations.SerializedName
data class FilterResult(
val filter: Filter,
@SerializedName("keyword_matches") val keywordMatches: List<String>?,
@SerializedName("status_matches") val statusMatches: String?
@SerializedName("status_matches") val statusMatches: List<String>?
)

View File

@ -19,7 +19,6 @@ import android.text.SpannableStringBuilder
import android.text.style.URLSpan
import com.google.gson.annotations.SerializedName
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import java.util.ArrayList
import java.util.Date
data class Status(
@ -42,7 +41,7 @@ data class Status(
val sensitive: Boolean,
@SerializedName("spoiler_text") val spoilerText: String,
val visibility: Visibility,
@SerializedName("media_attachments") val attachments: ArrayList<Attachment>,
@SerializedName("media_attachments") val attachments: List<Attachment>,
val mentions: List<Mention>,
val tags: List<HashTag>?,
val application: Application?,

View File

@ -21,15 +21,13 @@ import java.util.Date
* Mastodon API Documentation: https://docs.joinmastodon.org/methods/trends/#tags
*
* @param name The name of the hashtag (after the #). The "caturday" in "#caturday".
* @param url The URL to your mastodon instance list for this hashtag.
* (@param url The URL to your mastodon instance list for this hashtag.)
* @param history A list of [TrendingTagHistory]. Each element contains metrics per day for this hashtag.
* @param following This is not listed in the APIs at the time of writing, but an instance is delivering it.
* (@param following This is not listed in the APIs at the time of writing, but an instance is delivering it.)
*/
data class TrendingTag(
val name: String,
val url: String,
val history: List<TrendingTagHistory>,
val following: Boolean
val history: List<TrendingTagHistory>
)
/**

View File

@ -26,6 +26,7 @@ import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.core.os.BundleCompat
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
@ -92,7 +93,7 @@ class ViewImageFragment : ViewMediaFragment() {
super.onViewCreated(view, savedInstanceState)
val arguments = this.requireArguments()
val attachment = arguments.getParcelable<Attachment>(ARG_ATTACHMENT)
val attachment = BundleCompat.getParcelable(arguments, ARG_ATTACHMENT, Attachment::class.java)
this.shouldStartTransition = arguments.getBoolean(ARG_START_POSTPONED_TRANSITION)
val url: String?
var description: String? = null

View File

@ -146,18 +146,28 @@ interface MastodonApi {
): Response<Notification>
@GET("api/v1/markers")
fun markersWithAuth(
suspend fun markersWithAuth(
@Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String,
@Query("timeline[]") timelines: List<String>
): Single<Map<String, Marker>>
): Map<String, Marker>
@GET("api/v1/notifications")
fun notificationsWithAuth(
@FormUrlEncoded
@POST("api/v1/markers")
suspend fun updateMarkersWithAuth(
@Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String,
@Query("since_id") sinceId: String?
): Single<List<Notification>>
@Field("home[last_read_id]") homeLastReadId: String? = null,
@Field("notifications[last_read_id]") notificationsLastReadId: String? = null
): NetworkResult<Unit>
@GET("api/v1/notifications")
suspend fun notificationsWithAuth(
@Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String,
/** Return results immediately newer than this ID */
@Query("min_id") minId: String?
): Response<List<Notification>>
@POST("api/v1/notifications/clear")
suspend fun clearNotifications(): Response<ResponseBody>
@ -677,7 +687,7 @@ interface MastodonApi {
@FormUrlEncoded
@POST("api/v1/reports")
fun report(
suspend fun report(
@Field("account_id") accountId: String,
@Field("status_ids[]") statusIds: List<String>,
@Field("comment") comment: String,
@ -772,5 +782,5 @@ interface MastodonApi {
suspend fun unfollowTag(@Path("name") name: String): NetworkResult<HashTag>
@GET("api/v1/trends/tags")
suspend fun trendingTags(): Response<List<TrendingTag>>
suspend fun trendingTags(): NetworkResult<List<TrendingTag>>
}

View File

@ -1,42 +0,0 @@
/* Copyright 2018 Conny Duck
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountManager
import dagger.android.AndroidInjection
import javax.inject.Inject
class NotificationClearBroadcastReceiver : BroadcastReceiver() {
@Inject
lateinit var accountManager: AccountManager
override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context)
val accountId = intent.getLongExtra(NotificationHelper.ACCOUNT_ID, -1)
val account = accountManager.getAccountById(accountId)
if (account != null) {
account.activeNotifications = "[]"
accountManager.saveAccount(account)
}
}
}

View File

@ -20,11 +20,11 @@ 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 com.keylesspalace.tusky.worker.NotificationWorker
import dagger.android.AndroidInjection
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope

View File

@ -16,6 +16,7 @@ import android.util.Log
import androidx.annotation.StringRes
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.IntentCompat
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R
@ -83,7 +84,7 @@ class SendStatusService : Service(), Injectable {
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
if (intent.hasExtra(KEY_STATUS)) {
val statusToSend: StatusToSend = intent.getParcelableExtra(KEY_STATUS)
val statusToSend: StatusToSend = IntentCompat.getParcelableExtra(intent, KEY_STATUS, StatusToSend::class.java)
?: throw IllegalStateException("SendStatusService started without $KEY_STATUS extra")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

View File

@ -1,15 +1,21 @@
package com.keylesspalace.tusky.settings
import androidx.preference.PreferenceDataStore
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class AccountPreferenceHandler(
private val account: AccountEntity,
class AccountPreferenceDataStore @Inject constructor(
private val accountManager: AccountManager,
private val dispatchEvent: (PreferenceChangedEvent) -> Unit
private val eventHub: EventHub,
@ApplicationScope private val externalScope: CoroutineScope
) : PreferenceDataStore() {
private val account: AccountEntity = accountManager.activeAccount!!
override fun getBoolean(key: String, defValue: Boolean): Boolean {
return when (key) {
@ -29,6 +35,8 @@ class AccountPreferenceHandler(
accountManager.saveAccount(account)
dispatchEvent(PreferenceChangedEvent(key))
externalScope.launch {
eventHub.dispatch(PreferenceChangedEvent(key))
}
}
}

View File

@ -101,4 +101,7 @@ object PrefKeys {
const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies_v2" // This was changed once to reset an unintentionally set default.
const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts"
/** UI text scaling factor, stored as float, 100 = 100% = no scaling */
const val UI_TEXT_SCALE_RATIO = "uiTextScaleRatio"
}

View File

@ -14,6 +14,7 @@ import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreference
import com.keylesspalace.tusky.view.SliderPreference
import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference
class PreferenceParent(
@ -43,6 +44,15 @@ inline fun <A> PreferenceParent.emojiPreference(activity: A, builder: EmojiPicke
return pref
}
inline fun PreferenceParent.sliderPreference(
builder: SliderPreference.() -> Unit
): SliderPreference {
val pref = SliderPreference(context)
builder(pref)
addPref(pref)
return pref
}
inline fun PreferenceParent.switchPreference(
builder: SwitchPreference.() -> Unit
): SwitchPreference {

View File

@ -84,11 +84,26 @@ class EmojiSpan(val viewWeakReference: WeakReference<View>) : ReplacementSpan()
imageDrawable?.let { drawable ->
canvas.save()
val emojiSize = (paint.textSize * 1.1).toInt()
drawable.setBounds(0, 0, emojiSize, emojiSize)
// start with a width relative to the text size
var emojiWidth = paint.textSize * 1.1
var transY = bottom - drawable.bounds.bottom
transY -= paint.fontMetricsInt.descent / 2
// calculate the height, keeping the aspect ratio correct
val drawableWidth = drawable.intrinsicWidth
val drawableHeight = drawable.intrinsicHeight
var emojiHeight = emojiWidth / drawableWidth * drawableHeight
// how much vertical space there is draw the emoji
val drawableSpace = (bottom - top).toDouble()
// in case the calculated height is bigger than the available space, scale the emoji down, preserving aspect ratio
if (emojiHeight > drawableSpace) {
emojiWidth *= drawableSpace / emojiHeight
emojiHeight = drawableSpace
}
drawable.setBounds(0, 0, emojiWidth.toInt(), emojiHeight.toInt())
// vertically center the emoji in the line
val transY = top + (drawableSpace / 2 - emojiHeight / 2)
canvas.translate(x, transY.toFloat())
drawable.draw(canvas)

View File

@ -0,0 +1,69 @@
/*
* Copyright 2023 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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
import kotlin.time.TimeMark
import kotlin.time.TimeSource
/**
* Returns a flow that mirrors the original flow, but filters out values that occur within
* [timeout] of the previously emitted value. The first value is always emitted.
*
* Example:
*
* ```kotlin
* flow {
* emit(1)
* delay(90.milliseconds)
* emit(2)
* delay(90.milliseconds)
* emit(3)
* delay(1010.milliseconds)
* emit(4)
* delay(1010.milliseconds)
* emit(5)
* }.throttleFirst(1000.milliseconds)
* ```
*
* produces the following emissions.
*
* ```text
* 1, 4, 5
* ```
*
* @see kotlinx.coroutines.flow.debounce(Duration)
* @param timeout Emissions within this duration of the last emission are filtered
* @param timeSource Used to measure elapsed time. Normally only overridden in tests
*/
@OptIn(ExperimentalTime::class)
fun <T> Flow<T>.throttleFirst(
timeout: Duration,
timeSource: TimeSource = TimeSource.Monotonic
) = flow {
var marker: TimeMark? = null
collect {
if (marker == null || marker!!.elapsedNow() >= timeout) {
emit(it)
marker = timeSource.markNow()
}
}
}

View File

@ -2,25 +2,27 @@
package com.keylesspalace.tusky.util
import java.text.DecimalFormat
import java.text.NumberFormat
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.log10
import kotlin.math.ln
import kotlin.math.pow
import kotlin.math.sign
val shortLetters = arrayOf(' ', 'K', 'M', 'B', 'T', 'P', 'E')
private val numberFormatter: NumberFormat = NumberFormat.getInstance()
private val ln_1k = ln(1000.0)
fun shortNumber(number: Number): String {
val numberAsDouble = number.toDouble()
val nonNegativeValue = abs(numberAsDouble)
var sign = ""
if (numberAsDouble.sign < 0) { sign = "-" }
val value = floor(log10(nonNegativeValue)).toInt()
val base = value / 3
if (value >= 3 && base < shortLetters.size) {
return DecimalFormat("$sign#0.0").format(nonNegativeValue / 10.0.pow((base * 3).toDouble())) + shortLetters[base]
} else {
return DecimalFormat("$sign#,##0").format(nonNegativeValue)
}
/**
* Format numbers according to the current locale. Numbers < min have
* separators (',', '.', etc) inserted according to the locale.
*
* Numbers >= min are scaled down to that by multiples of 1,000, and
* a suffix appropriate to the scaling is appended.
*/
fun formatNumber(num: Long, min: Int = 100000): String {
val absNum = abs(num)
if (absNum < min) return numberFormatter.format(num)
val exp = (ln(absNum.toDouble()) / ln_1k).toInt()
// Suffixes here are locale-agnostic
return String.format("%.1f%c", num / 1000.0.pow(exp.toDouble()), "KMGTPE"[exp - 1])
}

View File

@ -1,74 +0,0 @@
package com.keylesspalace.tusky.util
import androidx.arch.core.util.Function
/**
* This list implementation can help to keep two lists in sync - like real models and view models.
*
* Every operation on the main list triggers update of the supplementary list (but not vice versa).
*
* This makes sure that the main list is always the source of truth.
*
* Main list is projected to the supplementary list by the passed mapper function.
*
* Paired list is newer actually exposed and clients are provided with `getPairedCopy()`,
* `getPairedItem()` and `setPairedItem()`. This prevents modifications of the
* supplementary list size so lists are always have the same length.
*
* This implementation will not try to recover from exceptional cases so lists may be out of sync
* after the exception.
*
* It is most useful with immutable data because we cannot track changes inside stored objects.
*
* @param T type of elements in the main list
* @param V type of elements in supplementary list
* @param mapper Function, which will be used to translate items from the main list to the
* supplementary one.
* @constructor
*/
class PairedList<T, V> (private val mapper: Function<T, out V>) : AbstractMutableList<T>() {
private val main: MutableList<T> = ArrayList()
private val synced: MutableList<V> = ArrayList()
val pairedCopy: List<V>
get() = ArrayList(synced)
fun getPairedItem(index: Int): V {
return synced[index]
}
fun getPairedItemOrNull(index: Int): V? {
return synced.getOrNull(index)
}
fun setPairedItem(index: Int, element: V) {
synced[index] = element
}
override fun get(index: Int): T {
return main[index]
}
override fun set(index: Int, element: T): T {
synced[index] = mapper.apply(element)
return main.set(index, element)
}
override fun add(element: T): Boolean {
synced.add(mapper.apply(element))
return main.add(element)
}
override fun add(index: Int, element: T) {
synced.add(index, mapper.apply(element))
main.add(index, element)
}
override fun removeAt(index: Int): T {
synced.removeAt(index)
return main.removeAt(index)
}
override val size: Int
get() = main.size
}

View File

@ -1,8 +1,11 @@
package com.keylesspalace.tusky.util
import android.content.Context
import com.keylesspalace.tusky.R
import org.json.JSONException
import org.json.JSONObject
import retrofit2.HttpException
import java.io.IOException
/**
* checks if this throwable indicates an error causes by a 4xx/5xx server response and
@ -24,3 +27,16 @@ fun Throwable.getServerErrorMessage(): String? {
}
return null
}
/** @return A drawable resource to accompany the error message for this throwable */
fun Throwable.getDrawableRes(): Int = when (this) {
is IOException -> R.drawable.elephant_offline
is HttpException -> R.drawable.elephant_offline
else -> R.drawable.elephant_error
}
/** @return A string error message for this throwable */
fun Throwable.getErrorString(context: Context): String = getServerErrorMessage() ?: when (this) {
is IOException -> context.getString(R.string.error_network)
else -> context.getString(R.string.error_generic)
}

View File

@ -40,7 +40,6 @@ import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TrendingViewData
@JvmName("statusToViewData")
fun Status.toViewData(
isShowingContent: Boolean,
isExpanded: Boolean,
@ -56,7 +55,6 @@ fun Status.toViewData(
)
}
@JvmName("notificationToViewData")
fun Notification.toViewData(
isShowingContent: Boolean,
isExpanded: Boolean,
@ -71,9 +69,20 @@ fun Notification.toViewData(
)
}
@JvmName("tagToViewData")
fun TrendingTag.toViewData(): TrendingViewData.Tag {
return TrendingViewData.Tag(
tag = this
)
fun List<TrendingTag>.toViewData(): List<TrendingViewData.Tag> {
val maxTrendingValue = flatMap { tag -> tag.history }
.mapNotNull { it.uses.toLongOrNull() }
.maxOrNull() ?: 1
return map { tag ->
val reversedHistory = tag.history.asReversed()
TrendingViewData.Tag(
name = tag.name,
usage = reversedHistory.mapNotNull { it.uses.toLongOrNull() },
accounts = reversedHistory.mapNotNull { it.accounts.toLongOrNull() },
maxTrendingValue = maxTrendingValue
)
}
}

View File

@ -18,6 +18,7 @@ package com.keylesspalace.tusky.util
import android.util.Log
import android.view.View
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
@ -66,3 +67,14 @@ fun ViewPager2.reduceSwipeSensitivity() {
Log.w("reduceSwipeSensitivity", e)
}
}
/**
* TextViews with an ancestor RecyclerView can forget that they are selectable. Toggling
* calls to [TextView.setTextIsSelectable] fixes this.
*
* @see https://issuetracker.google.com/issues/37095917
*/
fun TextView.fixTextSelection() {
setTextIsSelectable(false)
post { setTextIsSelectable(true) }
}

View File

@ -1,6 +1,7 @@
package com.keylesspalace.tusky.view
import android.content.Context
import android.text.method.LinkMovementMethod
import android.util.AttributeSet
import android.view.Gravity
import android.view.LayoutInflater
@ -12,6 +13,8 @@ import androidx.annotation.StringRes
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ViewBackgroundMessageBinding
import com.keylesspalace.tusky.util.addDrawables
import com.keylesspalace.tusky.util.getDrawableRes
import com.keylesspalace.tusky.util.getErrorString
import com.keylesspalace.tusky.util.visible
/**
@ -34,16 +37,27 @@ class BackgroundMessageView @JvmOverloads constructor(
}
}
fun setup(throwable: Throwable, listener: ((v: View) -> Unit)? = null) {
setup(throwable.getDrawableRes(), throwable.getErrorString(context), listener)
}
fun setup(
@DrawableRes imageRes: Int,
@StringRes messageRes: Int,
clickListener: ((v: View) -> Unit)? = null
) = setup(imageRes, context.getString(messageRes), clickListener)
/**
* Setup image, message and button.
* If [clickListener] is `null` then the button will be hidden.
*/
fun setup(
@DrawableRes imageRes: Int,
@StringRes messageRes: Int,
message: String,
clickListener: ((v: View) -> Unit)? = null
) {
binding.messageTextView.setText(messageRes)
binding.messageTextView.text = message
binding.messageTextView.movementMethod = LinkMovementMethod.getInstance()
binding.imageView.setImageResource(imageRes)
binding.button.setOnClickListener(clickListener)
binding.button.visible(clickListener != null)

View File

@ -114,6 +114,14 @@ class ClickableSpanTextView @JvmOverloads constructor(
lengthAfter: Int
) {
super.onTextChanged(text, start, lengthBefore, lengthAfter)
// TextView tries to optimise the layout process, and will not perform a layout if the
// new text takes the same area as the old text (see TextView.checkForRelayout()). This
// can result in statuses using the wrong clickable areas as they are never remeasured.
// (https://github.com/tuskyapp/Tusky/issues/3596). Force a layout pass to ensure that
// the spans are measured correctly.
if (!isInLayout) requestLayout()
doOnLayout { measureSpans() }
}
@ -126,11 +134,11 @@ class ClickableSpanTextView @JvmOverloads constructor(
* If the span runs over multiple lines there will be two Rects per line.
*/
private fun measureSpans() {
val spannedText = text as? Spanned ?: return
spanRects.clear()
delegateRects.clear()
val spannedText = text as? Spanned ?: return
// The goal is to record all the [Rect]s associated with a span with the same fidelity
// that the user sees when they highlight text in the view to select it.
//
@ -358,7 +366,7 @@ class ClickableSpanTextView @JvmOverloads constructor(
}
companion object {
const val TAG = "LinkTextView"
const val TAG = "ClickableSpanTextView"
}
}

View File

@ -22,10 +22,9 @@ import android.graphics.Path
import android.graphics.PathMeasure
import android.graphics.Rect
import android.util.AttributeSet
import android.view.View
import androidx.annotation.ColorInt
import androidx.annotation.Dimension
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.ContextCompat
import androidx.core.content.res.use
import com.keylesspalace.tusky.R
import kotlin.math.max
@ -33,9 +32,8 @@ import kotlin.math.max
class GraphView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
@get:ColorInt
@ColorInt
var primaryLineColor = 0
@ -55,7 +53,7 @@ class GraphView @JvmOverloads constructor(
@ColorInt
var metaColor = 0
var proportionalTrending = false
private var proportionalTrending = false
private lateinit var primaryLinePaint: Paint
private lateinit var secondaryLinePaint: Paint
@ -129,16 +127,14 @@ class GraphView @JvmOverloads constructor(
private fun initFromXML(attr: AttributeSet?) {
context.obtainStyledAttributes(attr, R.styleable.GraphView).use { a ->
primaryLineColor = ContextCompat.getColor(
context,
primaryLineColor = context.getColor(
a.getResourceId(
R.styleable.GraphView_primaryLineColor,
R.color.tusky_blue
)
)
secondaryLineColor = ContextCompat.getColor(
context,
secondaryLineColor = context.getColor(
a.getResourceId(
R.styleable.GraphView_secondaryLineColor,
R.color.tusky_red
@ -150,16 +146,14 @@ class GraphView @JvmOverloads constructor(
R.dimen.graph_line_thickness
).toFloat()
graphColor = ContextCompat.getColor(
context,
graphColor = context.getColor(
a.getResourceId(
R.styleable.GraphView_graphColor,
R.color.colorBackground
)
)
metaColor = ContextCompat.getColor(
context,
metaColor = context.getColor(
a.getResourceId(
R.styleable.GraphView_metaColor,
R.color.dividerColor

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