diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..47d830da0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +# Disable wildcard imports +[*.{java, kt}] +ij_kotlin_name_count_to_use_star_import = 999 +ij_kotlin_name_count_to_use_star_import_for_members = 999 +ij_java_class_count_to_use_import_on_demand = 999 + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..411c07777 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf + +*.bat text eol=crlf +*.jar binary diff --git a/.gitignore b/.gitignore index 4d7a1cd0d..6c034846d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,11 @@ /local.properties /.idea .DS_Store -/build +build /captures .externalNativeBuild app/release +app-release.apk app/green app/blue app/src/main/gen diff --git a/.idea/icon.png b/.idea/icon.png new file mode 100644 index 000000000..3132225d5 Binary files /dev/null and b/.idea/icon.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..23c421d92 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,98 @@ +# Tusky changelog + +## Unreleased or Tusky Nightly + +### New features and other improvements + +### Significant bug fixes + +## 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 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 20abafe6d..0960c91eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,51 +1,53 @@ # Contributing -## Getting Started -1. Fork the repository on the GitHub page by clicking the Fork button. This makes a fork of the project under your GitHub account. -2. Clone your fork to your machine. ```git clone https://github.com//Tusky``` -3. Create a new branch named after your change. ```git checkout -b your-change-name``` (```checkout``` switches to a branch, ```-b``` specifies that the branch is a new one) +Thanks for your interest in contributing to Tusky! Here are some informations to help you get started. -## Making Changes +If you have any questions, don't hesitate to open an issue or join our [development chat on Matrix](https://riot.im/app/#/room/#Tusky:matrix.org). -### Text -All English text that will be visible to users should be put in ```app/src/main/res/values/strings.xml```. Any text that is missing in a translation will fall back to the version in this file. Be aware that anything added to this file will need to be translated, so be very concise with wording and try to add as few things as possible. Look for existing strings to use first. If there is untranslatable text that you don't want to keep as a string constant in a Java class, you can use the string resource file ```app/src/main/res/values/donottranslate.xml```. +## Contributing translations -### Translation -Translations are done through our [Weblate](https://weblate.tusky.app/projects/tusky/tusky/). -To add a new language, click on the 'Start a new translation' button on at the bottom of the page. +Translations are managed on our [Weblate](https://weblate.tusky.app/projects/tusky/tusky/). You can create an account and translate texts through the interface, no coding knowledge required. +To add a new language, click on the 'Start a new translation' button on at the bottom of the page. + +- Use gender-neutral language +- Address users informally (e.g. in German "du" and never "Sie") + +## Contributing code + +### Prerequisites +You should have a general understanding of Android development and Git. + +### Architecture +We try to follow the [Guide to app architecture](https://developer.android.com/topic/architecture). ### Kotlin -This project is in the process of migrating to Kotlin, all new code must be written in Kotlin. +Tusky was originally written in Java, but is in the process of migrating to Kotlin. All new code must be written in Kotlin. We try to follow the [Kotlin Style Guide](https://developer.android.com/kotlin/style-guide) and make format the code according to the default [ktlint codestyle](https://github.com/pinterest/ktlint). You can check the codestyle by running `./gradlew ktlintCheck`. -### Java -Existing code in Java should follow the [Android Style Guide](https://source.android.com/source/code-style), which is what Android uses for their own source code. ```@Nullable``` and ```@NotNull``` annotations are really helpful for Kotlin interoperability. Please don't submit new features written in Java. +### Text +All English text that will be visible to users must be put in `app/src/main/res/values/strings.xml` so it is translateable into other languages. +Try to keep texts friendly and concise. +If there is untranslatable text that you don't want to keep as a string constant in Kotlin code, you can use the string resource file `app/src/main/res/values/donottranslate.xml`. ### Viewbinding We use [Viewbinding](https://developer.android.com/topic/libraries/view-binding) to reference views. No contribution using another mechanism will be accepted. There are useful extensions in `src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt` that make working with viewbinding easier. ### Visuals -There are three themes in the app, so any visual changes should be checked with each of them to ensure they look appropriate no matter which theme is selected. Usually, you can use existing color attributes like ```?attr/colorPrimary``` and ```?attr/textColorSecondary```. +There are three themes in the app, so any visual changes should be checked with each of them to ensure they look appropriate no matter which theme is selected. Usually, you can use existing color attributes like `?attr/colorPrimary` and `?attr/textColorSecondary`. +All icons are from the Material iconset, find new icons [here](https://fonts.google.com/icons) (Google fonts) or [here](https://fonts.google.com/icons) (community contributions). -### Saving -Any time you get a good chunk of work done it's good to make a commit. You can either uses Android Studio's built-in UI for doing this or running the commands: -``` -git add . -git commit -m "Describe the changes in this commit here." -``` +### Accessibility +We try to make Tusky as accessible as possible for as many people as possible. Please make sure that all touch targets are at least 48dpx48dp in size, Text has sufficient contrast and images or icons have a image description. See [this guide](https://developer.android.com/guide/topics/ui/accessibility/apps) for more information. -## Submitting Your Changes -1. Make sure your branch is up-to-date with the ```develop``` branch. Run: -``` -git fetch -git rebase origin/develop -``` -It may refuse to start the rebase if there's changes that haven't been committed, so make sure you've added and committed everything. If there were changes on develop to any of the parts of files you worked on, a conflict will arise when you rebase. [Resolving a merge conflict](https://help.github.com/articles/resolving-a-merge-conflict-using-the-command-line) is a good guide to help with this. After committing the resolution, you can run ```git rebase --continue``` to finish the rebase. If you want to cancel, like if you make some mistake in resolving the conflict, you can always do ```git rebase --abort```. +### Supported servers +Tusky is primarily a Mastodon client and aims to always support the newest Mastodon version. Other platforms implementing the Mastodon Api, e.g. Akkoma, GoToSocial or Pixelfed should also work with Tusky but no special effort is made to support their quirks or additional features. -2. Push your local branch to your fork on GitHub by running ```git push origin your-change-name```. -3. Then, go to the original project page and make a pull request. Select your fork/branch and use ```develop``` as the base branch. -4. Wait for feedback on your pull request and be ready to make some changes +## Troubleshooting / FAQ -If you have any questions, don't hesitate to open an issue or contact [Tusky@mastodon.social](https://mastodon.social/@Tusky). Please also ask before you start implementing a new big feature. +- Tusky should be built with the newest version of Android Studio +- Tusky comes with two sets of build variants, "blue" and "green", which can be installed simultaneously and are distinguished by the colors of their icons. Green is intended for local development and testing, whereas blue is for releases. + +## Resources +- [Mastodon Api documentation](https://docs.joinmastodon.org/api/) diff --git a/README.md b/README.md index 4903de10f..ea0e4c51f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Tusky is a beautiful Android client for [Mastodon](https://github.com/mastodon/m - Multi-Account support - Dark, light and black themes with the possibility to auto-switch based on the time of day - Drafts - compose posts and save them for later -- Choose between different emoji styles +- Choose between different emoji styles - Optimized for all screen sizes - Completely open-source - no non-free dependencies like Google services diff --git a/Release.md b/Release.md index 230a4024a..338d27297 100644 --- a/Release.md +++ b/Release.md @@ -7,20 +7,21 @@ This approach of having ~500 user on the nightly releases and ~5000 users on the ## Beta - Make sure all new features are well tested by Nightly users and all issues addressed as good as possible. Check GitHub issues, Google Play crash reports, messages on `@Tusky@mastodon.social`, emails on `tusky@connyduck.at`, #Tusky hashtag. -- Merge the latest Weblate translations (Weblate -> Repository maintenance -> commit all changes, then merge the automatic PRs by @nailyk-weblate on GitHub) +- Merge the latest Weblate translations (Weblate -> Repository maintenance -> commit all changes, then merge the automatic PRs by @nailyk-weblate on GitHub) - Check all the translations (Android Studio shows warnings on problems). Sometimes translators add faulty translations that would crash Tusky in this language, e.g. wrong number of formatting parameters. In this case it is usually easiest to just delete the string. [Example cleanup](https://github.com/tuskyapp/Tusky/commit/feaea70af418c77178985144a2d01a8e97725dfd). - Update `versionCode` and `versionName` in `app/build.gradle` - Add a new short changelog under `fastlane/metadata/android/en-US/changelogs`. Use the next versionCode as the filename. This is so translators on Weblate have the duration of the beta to translate the changelog and F-Droid users will see it in their language on the release. If another beta is released, the changelogs have to be renamed. Note that changelogs shouldn't be over 500 characters or F-Droid will truncate them. -- Build the app as apk and as app bundle. -- Do a quick check to make sure the build doesn't crash. Also install it over the last release to make sure the database migrations are correct. - Merge `develop` into `main` - Create a new [GitHub release](https://github.com/tuskyapp/Tusky/releases). - Tag the head of `main`. - Create an exhaustive changelog by going through all commits since the last release. - - Attach the apk, adb and mapping.txt files to the release - Mark the release as being a pre-release. +- Bitrise will automatically build and upload the release to the Internal Testing track on Google Play. +- Do a quick check to make sure the build doesn't crash, e.g. by enrolling yourself into the test track. + - In case there are any problems, delete the GitHub release, fix the problems and start again +- Download the build as apk from Google Play (App Bundle Explorer -> chose the release -> Downloads -> Signed, universal APK). Attach it to the GitHub Release. +- Create a new Open Testing release on Google Play. Reuse the build from the Internal Testing track. - Create a merge request at F-Droid. [Example](https://gitlab.com/fdroid/fdroiddata/-/merge_requests/11218) (F-Droid automatically picks up new release tags, but not beta ones. This could probably be changed somehow.) -- Upload the release to the Open Testing track on Google Play. - Announce the release ## Full release @@ -28,15 +29,16 @@ This approach of having ~500 user on the nightly releases and ~5000 users on the - Make sure all new features are well tested by beta users and all issues addressed as good as possible. Check GitHub issues, Google Play crash reports, messages on `@Tusky@mastodon.social`, #Tusky hashtag. - Merge the latest Weblate translations (Weblate -> Repository maintenance -> commit all changes, then merge the automatic PRs by @nailyk-weblate on GitHub) - Update `versionCode` and `versionName` in `app/build.gradle` -- Build the app as apk and as app bundle. -- Do a quick check to make sure the build doesn't crash. Also install it over the last release to make sure the database migrations are correct. - Merge `develop` into `main` - Create a new [GitHub release](https://github.com/tuskyapp/Tusky/releases). - Tag the head of `main`. - - Resuse the changelog from the beta release, or create a new one if this is only a minor release. - - Attach the apk, adb and mapping.txt files to the release + - Reuse the changelog from the beta release, or create a new one if this is only a minor release. - (F-Droid will automatically detect and build the release) -- Upload the release to the Production track on Google Play. +- Bitrise will automatically build and upload the release to the Internal Testing track on Google Play. +- Do a quick check to make sure the build doesn't crash, e.g. by enrolling yourself into the test track. + - In case there are any problems, delete the GitHub release, fix the problems and start again +- Download the build as apk from Google Play (App Bundle Explorer -> chose the release -> Downloads -> Signed, universal APK). Attach it to the GitHub Release. +- Create a new full release on Google Play. Reuse the build from the Internal Testing track. - update the download link on the homepage ([repo](https://github.com/tuskyapp/tuskyapp.github.io)) - Announce the release diff --git a/app/.gitignore b/app/.gitignore deleted file mode 100644 index 3f1ce47a1..000000000 --- a/app/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/build -app-release.apk diff --git a/app/build.gradle b/app/build.gradle index dcdab7230..0cd011d08 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,30 +1,33 @@ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-kapt' -apply plugin: 'kotlin-parcelize' - -apply from: "../instance-build.gradle" - -def getGitSha = { - def stdout = new ByteArrayOutputStream() - try { - exec { - commandLine 'git', 'rev-parse', '--short', 'HEAD' - standardOutput = stdout - } - } catch (Exception e) { - return "unknown" - } - return stdout.toString().trim() +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.kotlin.parcelize) } +apply from: 'getGitSha.gradle' + +final def gitSha = ext.getGitSha() + +// The app name +final def APP_NAME = "Yuito" +// The application id. Must be unique, e.g. based on your domain +final def APP_ID = "net.accelf.yuito" +// url of a custom app logo. Recommended size at least 600x600. Keep empty to use the Tusky elephant friend. +final def CUSTOM_LOGO_URL = "" +// e.g. mastodon.social. Keep empty to not suggest any instance on the signup screen +final def CUSTOM_INSTANCE = "" +// link to your support account. Will be linked on the about page when not empty. +final def SUPPORT_ACCOUNT_URL = "https://odakyu.app/@ars42525" + android { - compileSdkVersion 33 + compileSdk 33 + namespace "com.keylesspalace.tusky" defaultConfig { - applicationId 'net.accelf.yuito' - namespace 'com.keylesspalace.tusky' - minSdkVersion 23 - targetSdkVersion 33 + applicationId APP_ID + namespace "com.keylesspalace.tusky" + minSdk 23 + targetSdk 33 versionCode 55 versionName '4.5.2' testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -35,12 +38,6 @@ android { buildConfigField("String", "CUSTOM_LOGO_URL", "\"$CUSTOM_LOGO_URL\"") buildConfigField("String", "CUSTOM_INSTANCE", "\"$CUSTOM_INSTANCE\"") buildConfigField("String", "SUPPORT_ACCOUNT_URL", "\"$SUPPORT_ACCOUNT_URL\"") - - kapt { - arguments { - arg("room.schemaLocation", "$projectDir/schemas") - } - } } signingConfigs { debug { @@ -58,20 +55,26 @@ android { } } - flavorDimensions "color" + flavorDimensions += "color" productFlavors { blue {} green { resValue "string", "app_name", APP_NAME + " Test" applicationIdSuffix ".test" - versionNameSuffix "-" + getGitSha() + versionNameSuffix "-" + gitSha } } - lintOptions { - disable 'MissingTranslation' + lint { + lintConfig file("lint.xml") + // Regenerate by deleting app/lint-baseline.xml, then run: + // ./gradlew lintBlueDebug + baseline = file("lint-baseline.xml") } + buildFeatures { + buildConfig true + resValues true viewBinding true } testOptions { @@ -81,17 +84,19 @@ android { } unitTests.all { systemProperty 'robolectric.logging.enabled', 'true' + systemProperty 'robolectric.lazyload', 'ON' } } sourceSets { androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) } - packagingOptions { - // Exclude unneeded files added by libraries - exclude 'LICENSE_OFL' - exclude 'LICENSE_UNICODE' - } + // Exclude unneeded files added by libraries + packagingOptions.resources.excludes += [ + 'LICENSE_OFL', + 'LICENSE_UNICODE', + ] + bundle { language { // bundle all languages in every apk so the dynamic language switching works @@ -102,14 +107,34 @@ android { includeInApk false includeInBundle false } + // Can remove this once https://issuetracker.google.com/issues/260059413 is fixed. + // https://kotlinlang.org/docs/gradle-configure-project.html#gradle-java-toolchains-support + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + applicationVariants.configureEach { variant -> + variant.outputs.configureEach { + outputFileName = "Tusky_${variant.versionName}_${variant.versionCode}_${gitSha}_" + + "${variant.flavorName}_${buildType.name}.apk" + } + } } -repositories { - maven { - url 'https://maven.accelf.net/' +kapt { + arguments { + arg("room.schemaLocation", "$projectDir/schemas") + arg("room.incremental", "true") } } +configurations { + // JNI-only libraries don't play nicely with Robolectric + // see https://github.com/tuskyapp/Tusky/pull/3367 + testImplementation.exclude group: "org.conscrypt", module: "conscrypt-android" + testRuntime.exclude group: "org.conscrypt", module: "conscrypt-android" +} + // library versions are in PROJECT_ROOT/gradle/libs.versions.toml dependencies { implementation libs.kotlinx.coroutines.android @@ -145,11 +170,7 @@ dependencies { implementation libs.photoview implementation libs.bundles.material.drawer - implementation libs.material.typeface, { - artifact { - type = "aar" - } - } + implementation libs.material.typeface implementation libs.image.cropper @@ -158,6 +179,8 @@ dependencies { implementation libs.bouncycastle implementation libs.unified.push + implementation libs.bundles.xmldiff + testImplementation libs.androidx.test.junit testImplementation libs.robolectric testImplementation libs.bundles.mockito @@ -165,6 +188,8 @@ dependencies { testImplementation libs.androidx.core.testing testImplementation libs.kotlinx.coroutines.test testImplementation libs.androidx.work.testing + testImplementation libs.truth + testImplementation libs.turbine androidTestImplementation libs.espresso.core androidTestImplementation libs.androidx.room.testing diff --git a/app/getGitSha.gradle b/app/getGitSha.gradle new file mode 100644 index 000000000..53315b2bc --- /dev/null +++ b/app/getGitSha.gradle @@ -0,0 +1,27 @@ +import org.gradle.api.provider.ValueSourceParameters +import javax.inject.Inject + +// Must wrap this in a ValueSource in order to get well-defined fail behavior without confusing Gradle on repeat builds. +abstract class GitShaValueSource implements ValueSource { + @Inject abstract ExecOperations getExecOperations() + + @Override String obtain() { + try { + def output = new ByteArrayOutputStream() + + execOperations.exec { + it.commandLine 'git', 'rev-parse', '--short=8', 'HEAD' + it.standardOutput = output + } + return output.toString().trim() + } catch (GradleException ignore) { + // Git executable unavailable, or we are not building in a git repo. Fall through: + } + return "unknown" + } +} + +// Export closure +ext.getGitSha = { + providers.of(GitShaValueSource) {}.get() +} diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml new file mode 100644 index 000000000..03638736f --- /dev/null +++ b/app/lint-baseline.xml @@ -0,0 +1,7673 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/lint.xml b/app/lint.xml new file mode 100644 index 000000000..5fd297c32 --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/48.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/48.json new file mode 100644 index 000000000..e79d07bb1 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/48.json @@ -0,0 +1,1001 @@ +{ + "formatVersion": 1, + "database": { + "version": 48, + "identityHash": "1525fce0dc69b2d6cc013a622a5d95d7", + "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, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "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": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "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, `quote` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "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 + }, + { + "fieldPath": "quote", + "columnName": "quote", + "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, '1525fce0dc69b2d6cc013a622a5d95d7')" + ] + } +} diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/49.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/49.json new file mode 100644 index 000000000..e37c71c3a --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/49.json @@ -0,0 +1,1007 @@ +{ + "formatVersion": 1, + "database": { + "version": 49, + "identityHash": "b8a8585854f555031b95024ad4ddfcf4", + "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, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `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": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "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, `quote` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "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 + }, + { + "fieldPath": "quote", + "columnName": "quote", + "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, 'b8a8585854f555031b95024ad4ddfcf4')" + ] + } +} diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/50.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/50.json new file mode 100644 index 000000000..6bc65bab2 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/50.json @@ -0,0 +1,1001 @@ +{ + "formatVersion": 1, + "database": { + "version": 50, + "identityHash": "b197c702e0c5e717210681c899f0dd2b", + "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, `quote` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "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 + }, + { + "fieldPath": "quote", + "columnName": "quote", + "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, 'b197c702e0c5e717210681c899f0dd2b')" + ] + } +} diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/51.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/51.json new file mode 100644 index 000000000..6aab51c38 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/51.json @@ -0,0 +1,1008 @@ +{ + "formatVersion": 1, + "database": { + "version": 51, + "identityHash": "ba94f754ce7a57ea014422b5e02ec258", + "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, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `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": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "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, `quote` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "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 + }, + { + "fieldPath": "quote", + "columnName": "quote", + "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, 'ba94f754ce7a57ea014422b5e02ec258')" + ] + } +} diff --git a/app/src/green/res/mipmap-hdpi/ic_launcher.png b/app/src/green/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 9b4b69f05..000000000 Binary files a/app/src/green/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/green/res/mipmap-hdpi/ic_launcher.webp b/app/src/green/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 000000000..231a15857 Binary files /dev/null and b/app/src/green/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/green/res/mipmap-mdpi/ic_launcher.png b/app/src/green/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 0ec6ce8ff..000000000 Binary files a/app/src/green/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/green/res/mipmap-mdpi/ic_launcher.webp b/app/src/green/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 000000000..f792c8681 Binary files /dev/null and b/app/src/green/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/green/res/mipmap-xhdpi/ic_launcher.png b/app/src/green/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 445bff76e..000000000 Binary files a/app/src/green/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/green/res/mipmap-xhdpi/ic_launcher.webp b/app/src/green/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 000000000..4631f6daa Binary files /dev/null and b/app/src/green/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/green/res/mipmap-xxhdpi/ic_launcher.png b/app/src/green/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 0faa75e17..000000000 Binary files a/app/src/green/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/green/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/green/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 000000000..aa0e7d509 Binary files /dev/null and b/app/src/green/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/green/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/green/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 74cd8e7d3..000000000 Binary files a/app/src/green/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/green/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/green/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 000000000..2823f33ad Binary files /dev/null and b/app/src/green/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 932ba219d..1ce708fd6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -123,7 +123,7 @@ - + - + + + - + // Some servers return (unrelated) accounts for url searches (#2804) + // Verify that the account's url matches the query + viewAccount(account.id) return@subscribe } @@ -181,5 +184,5 @@ abstract class BottomSheetActivity : BaseActivity() { enum class PostLookupFallbackBehavior { OPEN_IN_BROWSER, - DISPLAY_ERROR, + DISPLAY_ERROR } diff --git a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt index 6071a6479..674b51bff 100644 --- a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt @@ -34,6 +34,7 @@ import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.resource.bitmap.FitCenter import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.canhub.cropper.CropImage import com.canhub.cropper.CropImageContract import com.canhub.cropper.options import com.google.android.material.snackbar.Snackbar @@ -80,14 +81,18 @@ class EditProfileActivity : BaseActivity(), Injectable { } private val cropImage = registerForActivityResult(CropImageContract()) { result -> - if (result.isSuccessful) { - if (result.uriContent == viewModel.getAvatarUri()) { - viewModel.newAvatarPicked() - } else { - viewModel.newHeaderPicked() - } + if (result is CropImage.CancelledResult) { + return@registerForActivityResult + } + + if (!result.isSuccessful) { + return@registerForActivityResult onPickFailure(result.error) + } + + if (result.uriContent == viewModel.getAvatarUri()) { + viewModel.newAvatarPicked() } else { - onPickFailure(result.error) + viewModel.newHeaderPicked() } } @@ -131,12 +136,11 @@ class EditProfileActivity : BaseActivity(), Injectable { is Success -> { val me = profileRes.data if (me != null) { - binding.displayNameEditText.setText(me.intentionallyUseDisplayName) binding.noteEditText.setText(me.source?.note) binding.lockedCheckBox.isChecked = me.locked - accountFieldEditAdapter.setFields(me.source?.fields ?: emptyList()) + accountFieldEditAdapter.setFields(me.source?.fields.orEmpty()) binding.addFieldButton.isVisible = (me.source?.fields?.size ?: 0) < maxAccountFields diff --git a/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt b/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt deleted file mode 100644 index bbb5bc6a1..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt +++ /dev/null @@ -1,183 +0,0 @@ -package com.keylesspalace.tusky - -import android.os.Bundle -import android.text.format.DateUtils -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.Toast -import androidx.lifecycle.lifecycleScope -import at.connyduck.calladapter.networkresult.fold -import at.connyduck.calladapter.networkresult.getOrElse -import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.PreferenceChangedEvent -import com.keylesspalace.tusky.databinding.ActivityFiltersBinding -import com.keylesspalace.tusky.entity.Filter -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.hide -import com.keylesspalace.tusky.util.show -import com.keylesspalace.tusky.util.viewBinding -import com.keylesspalace.tusky.view.getSecondsForDurationIndex -import com.keylesspalace.tusky.view.setupEditDialogForFilter -import com.keylesspalace.tusky.view.showAddFilterDialog -import kotlinx.coroutines.launch -import java.io.IOException -import javax.inject.Inject - -class FiltersActivity : BaseActivity() { - @Inject - lateinit var api: MastodonApi - - @Inject - lateinit var eventHub: EventHub - - private val binding by viewBinding(ActivityFiltersBinding::inflate) - - private lateinit var context: String - private lateinit var filters: MutableList - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContentView(binding.root) - setSupportActionBar(binding.includedToolbar.toolbar) - supportActionBar?.run { - // Back button - setDisplayHomeAsUpEnabled(true) - setDisplayShowHomeEnabled(true) - } - binding.addFilterButton.setOnClickListener { - showAddFilterDialog(this) - } - - title = intent?.getStringExtra(FILTERS_TITLE) - context = intent?.getStringExtra(FILTERS_CONTEXT)!! - loadFilters() - } - - fun updateFilter(id: String, phrase: String, filterContext: List, irreversible: Boolean, wholeWord: Boolean, expiresInSeconds: Int?, itemIndex: Int) { - lifecycleScope.launch { - api.updateFilter(id, phrase, filterContext, irreversible, wholeWord, expiresInSeconds).fold( - { updatedFilter -> - if (updatedFilter.context.contains(context)) { - filters[itemIndex] = updatedFilter - } else { - filters.removeAt(itemIndex) - } - refreshFilterDisplay() - eventHub.dispatch(PreferenceChangedEvent(context)) - }, - { - Toast.makeText(this@FiltersActivity, "Error updating filter '$phrase'", Toast.LENGTH_SHORT).show() - } - ) - } - } - - fun deleteFilter(itemIndex: Int) { - val filter = filters[itemIndex] - if (filter.context.size == 1) { - lifecycleScope.launch { - // This is the only context for this filter; delete it - api.deleteFilter(filters[itemIndex].id).fold( - { - filters.removeAt(itemIndex) - refreshFilterDisplay() - eventHub.dispatch(PreferenceChangedEvent(context)) - }, - { - Toast.makeText(this@FiltersActivity, "Error deleting filter '${filters[itemIndex].phrase}'", Toast.LENGTH_SHORT).show() - } - ) - } - } else { - // Keep the filter, but remove it from this context - val oldFilter = filters[itemIndex] - val newFilter = Filter( - oldFilter.id, oldFilter.phrase, oldFilter.context.filter { c -> c != context }, - oldFilter.expiresAt, oldFilter.irreversible, oldFilter.wholeWord - ) - updateFilter( - newFilter.id, newFilter.phrase, newFilter.context, newFilter.irreversible, newFilter.wholeWord, - getSecondsForDurationIndex(-1, this, oldFilter.expiresAt), itemIndex - ) - } - } - - fun createFilter(phrase: String, wholeWord: Boolean, expiresInSeconds: Int? = null) { - lifecycleScope.launch { - api.createFilter(phrase, listOf(context), false, wholeWord, expiresInSeconds).fold( - { filter -> - filters.add(filter) - refreshFilterDisplay() - eventHub.dispatch(PreferenceChangedEvent(context)) - }, - { - Toast.makeText(this@FiltersActivity, "Error creating filter '$phrase'", Toast.LENGTH_SHORT).show() - } - ) - } - } - - private fun refreshFilterDisplay() { - binding.filtersView.adapter = ArrayAdapter( - this, - android.R.layout.simple_list_item_1, - filters.map { filter -> - if (filter.expiresAt == null) { - filter.phrase - } else { - getString( - R.string.filter_expiration_format, - filter.phrase, - DateUtils.getRelativeTimeSpanString( - filter.expiresAt.time, - System.currentTimeMillis(), - DateUtils.MINUTE_IN_MILLIS, - DateUtils.FORMAT_ABBREV_RELATIVE - ) - ) - } - } - ) - binding.filtersView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> setupEditDialogForFilter(this, filters[position], position) } - } - - private fun loadFilters() { - - binding.filterMessageView.hide() - binding.filtersView.hide() - binding.addFilterButton.hide() - binding.filterProgressBar.show() - - lifecycleScope.launch { - val newFilters = api.getFilters().getOrElse { - binding.filterProgressBar.hide() - binding.filterMessageView.show() - if (it is IOException) { - binding.filterMessageView.setup( - R.drawable.elephant_offline, - R.string.error_network - ) { loadFilters() } - } else { - binding.filterMessageView.setup( - R.drawable.elephant_error, - R.string.error_generic - ) { loadFilters() } - } - return@launch - } - - filters = newFilters.filter { it.context.contains(context) }.toMutableList() - refreshFilterDisplay() - - binding.filtersView.show() - binding.addFilterButton.show() - binding.filterProgressBar.hide() - } - } - - companion object { - const val FILTERS_CONTEXT = "filters_context" - const val FILTERS_TITLE = "filters_title" - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt b/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt index 54fba19aa..ff4651158 100644 --- a/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt @@ -45,7 +45,6 @@ class LicenseActivity : BaseActivity() { } private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) { - val sb = StringBuilder() val br = BufferedReader(InputStreamReader(resources.openRawResource(fileId))) diff --git a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt index 436bef7a5..ee0187986 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt @@ -31,6 +31,7 @@ import android.widget.TextView import androidx.activity.viewModels import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog +import androidx.core.widget.doOnTextChanged import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DividerItemDecoration @@ -45,7 +46,6 @@ import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.util.hide -import com.keylesspalace.tusky.util.onTextChanged import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible @@ -101,6 +101,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { DividerItemDecoration(this, DividerItemDecoration.VERTICAL) ) + binding.swipeRefreshLayout.setOnRefreshListener { viewModel.retryLoading() } + binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + lifecycleScope.launch { viewModel.state.collect(this@ListsActivity::update) } @@ -113,7 +116,6 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { lifecycleScope.launch { viewModel.events.collect { event -> - @Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA") when (event) { Event.CREATE_ERROR -> showMessage(R.string.error_create_list) Event.RENAME_ERROR -> showMessage(R.string.error_rename_list) @@ -135,8 +137,11 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { val dialog = AlertDialog.Builder(this) .setView(layout) .setPositiveButton( - if (list == null) R.string.action_create_list - else R.string.action_rename_list + if (list == null) { + R.string.action_create_list + } else { + R.string.action_rename_list + } ) { _, _ -> onPickedDialogName(editText.text, list?.id) } @@ -144,8 +149,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { .show() val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE) - editText.onTextChanged { s, _, _, _ -> - positiveButton.isEnabled = s.isNotBlank() + editText.doOnTextChanged { s, _, _, _ -> + positiveButton.isEnabled = s?.isNotBlank() == true } editText.setText(list?.title) editText.text?.let { editText.setSelection(it.length) } @@ -164,6 +169,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { private fun update(state: ListsViewModel.State) { adapter.submitList(state.lists) binding.progressBar.visible(state.loadingState == LOADING) + binding.swipeRefreshLayout.isRefreshing = state.loadingState == LOADING when (state.loadingState) { INITIAL, LOADING -> binding.messageView.hide() ERROR_NETWORK -> { @@ -182,7 +188,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { if (state.lists.isEmpty()) { binding.messageView.show() binding.messageView.setup( - R.drawable.elephant_friend_empty, R.string.message_empty, + R.drawable.elephant_friend_empty, + R.string.message_empty, null ) } else { @@ -193,7 +200,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { private fun showMessage(@StringRes messageId: Int) { Snackbar.make( - binding.listsRecycler, messageId, Snackbar.LENGTH_SHORT + binding.listsRecycler, + messageId, + Snackbar.LENGTH_SHORT ).show() } diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 9df9e17b0..80f9ad943 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -30,11 +30,13 @@ import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.InsetDrawable import android.net.Uri -import android.os.Build import android.os.Bundle +import android.text.TextUtils import android.util.Log import android.util.TypedValue import android.view.KeyEvent +import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.WindowManager @@ -42,20 +44,19 @@ import android.widget.ImageView import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog +import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.view.menu.MenuBuilder import androidx.appcompat.widget.PopupMenu -import androidx.appcompat.widget.ThemeUtils import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.view.GravityCompat -import androidx.lifecycle.Lifecycle +import androidx.core.view.MenuProvider import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.viewpager2.widget.MarginPageTransformer import at.connyduck.calladapter.networkresult.fold -import autodispose2.androidx.lifecycle.autoDispose import com.bumptech.glide.Glide import com.bumptech.glide.RequestManager import com.bumptech.glide.load.resource.bitmap.RoundedCorners @@ -68,18 +69,19 @@ import com.google.android.material.tabs.TabLayout.OnTabSelectedListener import com.google.android.material.tabs.TabLayoutMediator import com.keylesspalace.tusky.appstore.AnnouncementReadEvent import com.keylesspalace.tusky.appstore.CacheUpdater -import com.keylesspalace.tusky.appstore.Event import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.MainTabsChangedEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.components.account.AccountActivity +import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType import com.keylesspalace.tusky.components.drafts.DraftsActivity import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.notifications.NotificationHelper +import com.keylesspalace.tusky.components.notifications.NotificationsFragment import com.keylesspalace.tusky.components.notifications.disableAllNotifications import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary @@ -87,15 +89,16 @@ import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity import com.keylesspalace.tusky.components.search.SearchActivity import com.keylesspalace.tusky.components.timeline.TimelineFragment +import com.keylesspalace.tusky.components.trending.TrendingActivity import com.keylesspalace.tusky.databinding.ActivityMainBinding import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.DraftsAlert import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.fragment.NotificationsFragment import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.ActionButtonActivity +import com.keylesspalace.tusky.interfaces.FabFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.ResettableFragment import com.keylesspalace.tusky.pager.MainPagerAdapter @@ -109,6 +112,7 @@ import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.reduceSwipeSensitivity import com.keylesspalace.tusky.util.setDrawableTint import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.updateShortcut import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible @@ -143,16 +147,15 @@ import com.mikepenz.materialdrawer.widget.AccountHeaderView import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.job import kotlinx.coroutines.launch import net.accelf.yuito.FooterDrawerItem import net.accelf.yuito.QuickTootViewModel import net.accelf.yuito.streaming.StreamingManager import javax.inject.Inject -class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector { +class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, MenuProvider { @Inject lateinit var androidInjector: DispatchingAndroidInjector @@ -188,7 +191,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private var unreadAnnouncementsCount = 0 - private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) } + private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) } private lateinit var glide: RequestManager @@ -197,6 +200,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje // We need to know if the emoji pack has been changed private var selectedEmojiPack: String? = null + /** Mediate between binding.viewPager and the chosen tab layout */ + private var tabLayoutMediator: TabLayoutMediator? = null + + /** Adapter for the different timeline tabs */ + private lateinit var tabAdapter: MainPagerAdapter + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -231,7 +240,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } else { // No account was provided, show the chooser showAccountChooserDialog( - getString(R.string.action_share_as), true, + getString(R.string.action_share_as), + true, object : AccountSelectionListener { override fun onAccountSelected(account: AccountEntity) { val requestedId = account.id @@ -263,6 +273,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own setContentView(binding.root) + setSupportActionBar(binding.mainToolbar) glide = Glide.with(this) @@ -274,21 +285,15 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje loadDrawerAvatar(activeAccount.profilePictureUrl, true) - binding.mainToolbar.menu.add(R.string.action_search).apply { - setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) - icon = IconicsDrawable(this@MainActivity, GoogleMaterial.Icon.gmd_search).apply { - sizeDp = 20 - colorInt = MaterialColors.getColor(binding.mainToolbar, android.R.attr.textColorPrimary) - } - setOnMenuItemClickListener { - startActivity(SearchActivity.getIntent(this@MainActivity)) - true - } - } + addMenuProvider(this) binding.viewPager.reduceSwipeSensitivity() - setupDrawer(savedInstanceState, addSearchButton = hideTopToolbar) + setupDrawer( + savedInstanceState, + addSearchButton = hideTopToolbar, + addTrendingButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING) + ) /* Fetch user info while we're doing other things. This has to be done after setting up the * drawer, though, because its callback touches the header in the drawer. */ @@ -296,19 +301,40 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje fetchAnnouncements() + streamingManager.setup(lifecycleScope.coroutineContext.job) { active -> + if (active) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + + // Initialise the tab adapter and set to viewpager. Fragments appear to be leaked if the + // adapter changes over the life of the viewPager (the adapter, not its contents), so set + // the initial list of tabs to empty, and set the full list later in setupTabs(). See + // https://github.com/tuskyapp/Tusky/issues/3251 for details. + tabAdapter = MainPagerAdapter(emptyList(), this) + binding.viewPager.adapter = tabAdapter + setupTabs(showNotificationTab) if (PreferenceManager.getDefaultSharedPreferences(this).getBoolean(PrefKeys.VIEW_PAGER_OFF_SCREEN_LIMIT, false)) { binding.viewPager.offscreenPageLimit = 9 } - eventHub.events - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_DESTROY) - .subscribe { event: Event? -> + lifecycleScope.launch { + eventHub.events.collect { event -> when (event) { is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData) - is MainTabsChangedEvent -> setupTabs(false) + is MainTabsChangedEvent -> { + refreshMainDrawerItems( + addSearchButton = hideTopToolbar, + addTrendingButton = !event.newTabs.hasTab(TRENDING) + ) + + setupTabs(false) + } + is AnnouncementReadEvent -> { unreadAnnouncementsCount-- updateAnnouncementsBadge() @@ -316,6 +342,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } binding.viewQuickToot.handleEvent(event) } + } Schedulers.io().scheduleDirect { // Flush old media that was cached for sharing @@ -355,14 +382,28 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje draftsAlert.observeInContext(this, true) } - override fun onPause() { - super.onPause() - streamingManager.pause() + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.activity_main, menu) + menu.findItem(R.id.action_search)?.apply { + icon = IconicsDrawable(this@MainActivity, GoogleMaterial.Icon.gmd_search).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.mainToolbar, android.R.attr.textColorPrimary) + } + } + } + + override fun onMenuItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_search -> { + startActivity(SearchActivity.getIntent(this@MainActivity)) + true + } + else -> super.onOptionsItemSelected(item) + } } override fun onResume() { super.onResume() - NotificationHelper.clearNotificationsForActiveAccount(this, accountManager) val currentEmojiPack = preferences.getString(EMOJI_PREFERENCE, "") if (currentEmojiPack != selectedEmojiPack) { Log.d( @@ -373,7 +414,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje selectedEmojiPack = currentEmojiPack recreate() } - streamingManager.resume() } override fun onStart() { @@ -382,20 +422,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje if (binding.mainDrawerLayout.isOpen) { binding.mainDrawerLayout.closeDrawer(GravityCompat.START, false) } - keepScreenOn() - } - - private fun keepScreenOn() { - if (streamingManager.active) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - } - - override fun onStop() { - super.onStop() - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { @@ -417,7 +443,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje // FIXME: blackberry keyONE raises SHIFT key event even CTRL IS PRESSED when (keyCode) { KeyEvent.KEYCODE_N -> { - // open compose activity by pressing SHIFT + N (or CTRL + N) val composeIntent = Intent(applicationContext, ComposeActivity::class.java) startActivity(composeIntent) @@ -449,8 +474,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje finish() } - private fun setupDrawer(savedInstanceState: Bundle?, addSearchButton: Boolean) { - + private fun setupDrawer( + savedInstanceState: Bundle?, + addSearchButton: Boolean, + addTrendingButton: Boolean + ) { val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() } binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener) @@ -475,6 +503,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje closeDrawerOnProfileListClick = true } + header.currentProfileName.maxLines = 1 + header.currentProfileName.ellipsize = TextUtils.TruncateAt.END + header.accountHeaderBackground.setColorFilter(getColor(R.color.headerBackgroundFilter)) header.accountHeaderBackground.setBackgroundColor(MaterialColors.getColor(header, R.attr.colorBackgroundAccent)) val animateAvatars = preferences.getBoolean("animateGifAvatars", false) @@ -507,6 +538,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje }) binding.mainDrawer.apply { + refreshMainDrawerItems(addSearchButton, addTrendingButton) + setSavedInstance(savedInstanceState) + } + } + + private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingButton: Boolean) { + binding.mainDrawer.apply { + itemAdapter.clear() tintStatusBar = true addItems( primaryDrawerItem { @@ -572,8 +611,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context)) } badgeStyle = BadgeStyle().apply { - textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, R.attr.colorOnPrimary)) - color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, R.attr.colorPrimary)) + textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorOnPrimary)) + color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorPrimary)) } }, DividerDrawerItem(), @@ -626,7 +665,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje ) } - setSavedInstance(savedInstanceState) + if (addTrendingButton) { + binding.mainDrawer.addItemsAtPosition( + 5, + primaryDrawerItem { + nameRes = R.string.title_public_trending_hashtags + iconicsIcon = GoogleMaterial.Icon.gmd_trending_up + onClick = { + startActivityWithSlideInAnimation(TrendingActivity.getIntent(context)) + } + } + ) + } } if (BuildConfig.DEBUG) { @@ -692,8 +742,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } private fun setupTabs(selectNotificationTab: Boolean) { - val activeTabLayout = if (preferences.getString("mainNavPosition", "top") == "bottom") { - val actionBarSize = getDimension(this, R.attr.actionBarSize) + val activeTabLayout = if (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom") { + val actionBarSize = getDimension(this, androidx.appcompat.R.attr.actionBarSize) val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin) (binding.composeButton.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = actionBarSize + fabMargin binding.topNav.hide() @@ -705,22 +755,31 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje binding.tabLayout } - val tabs = accountManager.activeAccount!!.tabPreferences.toMutableList() + // Save the previous tab so it can be restored later + val previousTab = tabAdapter.tabs.getOrNull(binding.viewPager.currentItem) - val adapter = MainPagerAdapter(tabs, this) - binding.viewPager.adapter = adapter - TabLayoutMediator(activeTabLayout, binding.viewPager) { _: TabLayout.Tab?, _: Int -> }.attach() - activeTabLayout.removeAllTabs() - val popups = ArrayList() - for (i in tabs.indices) { - val tab = activeTabLayout.newTab() - .setIcon(tabs[i].icon) - if (tabs[i].id == LIST) { - tab.contentDescription = tabs[i].arguments[1] - } else { - tab.setContentDescription(tabs[i].text) + val tabs = accountManager.activeAccount!!.tabPreferences.toMutableList() + val popups = MutableList(tabs.size) { null } + + // Detach any existing mediator before changing tab contents and attaching a new mediator + tabLayoutMediator?.detach() + + tabAdapter.tabs = tabs + tabAdapter.notifyItemRangeChanged(0, tabs.size) + + tabLayoutMediator = TabLayoutMediator(activeTabLayout, binding.viewPager, true) { + tab: TabLayout.Tab, position: Int -> + val data = tabs[position] + + tab.icon = AppCompatResources.getDrawable(this@MainActivity, data.icon) + tab.contentDescription = when (data.id) { + LIST -> data.arguments[1] + else -> getString(data.text) + } + + if (popups[position] != null) { + return@TabLayoutMediator } - activeTabLayout.addTab(tab) val popup = PopupMenu(this, tab.view) popup.menuInflater.inflate(R.menu.view_tab_action, popup.menu) @@ -729,19 +788,19 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje if (popup.menu is MenuBuilder) { val menuBuilder = popup.menu as MenuBuilder - if (tabs[i].id in arrayOf(HOME, LOCAL, FEDERATED, HASHTAG, LIST)) { + if (data.id in arrayOf(HOME, LOCAL, FEDERATED, HASHTAG, LIST)) { menuBuilder.findItem(R.id.tabReset).isVisible = true } - if (tabs[i].id == LIST) { + if (data.id == LIST) { menuBuilder.findItem(R.id.tabEditList).isVisible = true } - if (tabs[i].id in arrayOf(HOME, LOCAL, FEDERATED, LIST)) { + if (data.id in arrayOf(HOME, LOCAL, FEDERATED, LIST)) { menuBuilder.findItem(R.id.tabToggleStreaming).apply { isVisible = true - isChecked = tabs[i].enableStreaming + isChecked = data.enableStreaming } } - if (tabs[i].id == NOTIFICATIONS) { + if (data.id == NOTIFICATIONS) { menuBuilder.findItem(R.id.tabToggleNotificationsFilter).isVisible = true } @@ -750,15 +809,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje val iconMarginPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f, resources.displayMetrics).toInt() if (item.icon != null) { - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) { - item.icon = InsetDrawable(item.icon, iconMarginPx, 0, iconMarginPx, 0) - } else { - item.icon = object : InsetDrawable(item.icon, iconMarginPx, 0, iconMarginPx, 0) { - override fun getIntrinsicWidth(): Int { - return intrinsicHeight + iconMarginPx + iconMarginPx - } - } - } + item.icon = InsetDrawable(item.icon, iconMarginPx, 0, iconMarginPx, 0) setDrawableTint(this, item.icon!!, android.R.attr.textColorPrimary) } } @@ -766,12 +817,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } popup.setOnMenuItemClickListener { item -> - val fragment = adapter.getFragment(tab.position) + val fragment = tabAdapter.getFragment(tab.position) when (item.itemId) { R.id.tabJumpToTop -> { if (fragment is ReselectableFragment) { (fragment as ReselectableFragment).onReselect() } + + refreshComposeButtonState(tabAdapter, tab.position) } R.id.tabReset -> { if (fragment is ResettableFragment) { @@ -780,27 +833,21 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } R.id.tabEditList -> { AccountsInListFragment.newInstance( - tabs[i].arguments.getOrNull(0).orEmpty(), - tabs[i].arguments.getOrNull(1).orEmpty() + data.arguments.getOrNull(0).orEmpty(), + data.arguments.getOrNull(1).orEmpty() ).show(supportFragmentManager, null) } R.id.tabToggleStreaming -> { if (fragment is TimelineFragment) { - val current = fragment.toggleStreaming() - item.isChecked = current + val to = !item.isChecked + fragment.setStreamingEnabled(to) + item.isChecked = to tintCheckIcon(item) - keepScreenOn() - - tabs[i] = tabs[i].copy(enableStreaming = current) + tabs[position] = data.copy(enableStreaming = to) accountManager.activeAccount?.let { - Single.fromCallable { - it.tabPreferences = tabs - accountManager.saveAccount(it) - } - .subscribeOn(Schedulers.io()) - .autoDispose(this, Lifecycle.Event.ON_DESTROY) - .subscribe() + it.tabPreferences = tabs + accountManager.saveAccount(it) } } } @@ -812,22 +859,28 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje !prefs.getBoolean("showNotificationsFilter", true) ) .apply() - eventHub.dispatch(PreferenceChangedEvent("showNotificationsFilter")) + lifecycleScope.launch { + eventHub.dispatch(PreferenceChangedEvent("showNotificationsFilter")) + } } } } false } - popups.add(popup) + popups[position] = popup + }.also { it.attach() } - if (tabs[i].id == NOTIFICATIONS) { - notificationTabPosition = i - if (selectNotificationTab) { - tab.select() - } - } - } + // Selected tab is either + // - Notification tab (if appropriate) + // - The previously selected tab (if it hasn't been removed) + // - Left-most tab + val position = if (selectNotificationTab) { + tabs.indexOfFirst { it.id == NOTIFICATIONS } + } else { + previousTab?.let { tabs.indexOfFirst { it == previousTab } } + }.takeIf { it != -1 } ?: 0 + binding.viewPager.setCurrentItem(position, false) val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin) binding.viewPager.setPageTransformer(MarginPageTransformer(pageMargin)) @@ -841,31 +894,41 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje onTabSelectedListener = object : OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab) { - if (tab.position == notificationTabPosition) { - NotificationHelper.clearNotificationsForActiveAccount(this@MainActivity, accountManager) - } + binding.mainToolbar.title = tab.contentDescription - binding.mainToolbar.title = tabs[tab.position].title(this@MainActivity) + refreshComposeButtonState(tabAdapter, tab.position) } override fun onTabUnselected(tab: TabLayout.Tab) {} override fun onTabReselected(tab: TabLayout.Tab) { - popups[tab.position].show() + popups[tab.position]?.show() } }.also { activeTabLayout.addOnTabSelectedListener(it) } val activeTabPosition = if (selectNotificationTab) notificationTabPosition else 0 - binding.mainToolbar.title = tabs[activeTabPosition].title(this@MainActivity) + supportActionBar?.title = tabs[activeTabPosition].title(this@MainActivity) binding.mainToolbar.setOnClickListener { - (adapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect() + (tabAdapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect() } updateProfiles() + } - keepScreenOn() + private fun refreshComposeButtonState(adapter: MainPagerAdapter, tabPosition: Int) { + adapter.getFragment(tabPosition)?.also { fragment -> + if (fragment is FabFragment) { + if (fragment.isFabVisible()) { + binding.composeButton.show() + } else { + binding.composeButton.hide() + } + } else { + binding.composeButton.show() + } + } } private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean { @@ -969,7 +1032,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) { - val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false) val animateAvatars = preferences.getBoolean("animateGifAvatars", false) @@ -997,7 +1059,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje .into(avatarView) } } else { - binding.bottomNavAvatar.hide() binding.topNavAvatar.hide() @@ -1104,16 +1165,17 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private fun updateProfiles() { val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) - val profiles: MutableList = accountManager.getAllAccountsOrderedByActive().map { acc -> - ProfileDrawerItem().apply { - isSelected = acc.isActive - nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis) - iconUrl = acc.profilePictureUrl - isNameShown = true - identifier = acc.id - descriptionText = acc.fullName - } - }.toMutableList() + val profiles: MutableList = + accountManager.getAllAccountsOrderedByActive().map { acc -> + ProfileDrawerItem().apply { + isSelected = acc.isActive + nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis) + iconUrl = acc.profilePictureUrl + isNameShown = true + identifier = acc.id + descriptionText = acc.fullName + } + }.toMutableList() // reuse the already existing "add account" item for (profile in header.profiles.orEmpty()) { @@ -1127,7 +1189,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje header.setActiveProfile(accountManager.activeAccount!!.id) binding.mainToolbar.subtitle = if (accountManager.shouldDisplaySelfUsername(this)) { accountManager.activeAccount!!.fullName - } else null + } else { + null + } } override fun getActionButton() = binding.composeButton diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index 2b8e854ac..3244cd21b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -23,11 +23,8 @@ import android.view.Menu import android.view.MenuItem import androidx.activity.viewModels import androidx.fragment.app.commit -import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import at.connyduck.calladapter.networkresult.fold -import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider -import autodispose2.autoDispose import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent @@ -36,12 +33,13 @@ import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.K import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.util.viewBinding import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import kotlinx.coroutines.launch import net.accelf.yuito.QuickTootViewModel +import retrofit2.HttpException import javax.inject.Inject class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { @@ -65,6 +63,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { private var unmuteTagItem: MenuItem? = null /** The filter muting hashtag, null if unknown or hashtag is not filtered */ + private var mutedFilterV1: FilterV1? = null private var mutedFilter: Filter? = null override fun onCreate(savedInstanceState: Bundle?) { @@ -104,10 +103,9 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { binding.viewQuickToot.attachViewModel(quickTootViewModel, this) - eventHub.events - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe(binding.viewQuickToot::handleEvent) + lifecycleScope.launch { + eventHub.events.collect(binding.viewQuickToot::handleEvent) + } binding.floatingBtn.setOnClickListener(binding.viewQuickToot::onFABClicked) } @@ -193,49 +191,89 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { lifecycleScope.launch { mastodonApi.getFilters().fold( { filters -> - for (filter in filters) { - if ((tag == filter.phrase) and filter.context.contains(Filter.HOME)) { - Log.d(TAG, "Tag $hashtag is filtered") - muteTagItem?.isVisible = false - unmuteTagItem?.isVisible = true - mutedFilter = filter - return@fold + mutedFilter = filters.firstOrNull { filter -> + filter.context.contains(Filter.Kind.HOME.kind) && filter.keywords.any { + it.keyword == tag } } - - Log.d(TAG, "Tag $hashtag is not filtered") - mutedFilter = null - muteTagItem?.isEnabled = true - muteTagItem?.isVisible = true - muteTagItem?.isVisible = true + updateTagMuteState(mutedFilter != null) }, { throwable -> - Log.e(TAG, "Error getting filters: $throwable") + if (throwable is HttpException && throwable.code() == 404) { + mastodonApi.getFiltersV1().fold( + { filters -> + mutedFilterV1 = filters.firstOrNull { filter -> + tag == filter.phrase && filter.context.contains(FilterV1.HOME) + } + updateTagMuteState(mutedFilterV1 != null) + }, + { throwable -> + Log.e(TAG, "Error getting filters: $throwable") + } + ) + } else { + Log.e(TAG, "Error getting filters: $throwable") + } } ) } } + private fun updateTagMuteState(muted: Boolean) { + if (muted) { + muteTagItem?.isVisible = false + muteTagItem?.isEnabled = false + unmuteTagItem?.isVisible = true + } else { + unmuteTagItem?.isVisible = false + muteTagItem?.isEnabled = true + muteTagItem?.isVisible = true + } + } + private fun muteTag(): Boolean { val tag = hashtag ?: return true lifecycleScope.launch { mastodonApi.createFilter( - tag, - listOf(Filter.HOME), - irreversible = false, - wholeWord = true, + title = "#$tag", + context = listOf(FilterV1.HOME), + filterAction = Filter.Action.WARN.action, expiresInSeconds = null ).fold( { filter -> - mutedFilter = filter - muteTagItem?.isVisible = false - unmuteTagItem?.isVisible = true - eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) + if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tag, wholeWord = true).isSuccess) { + mutedFilter = filter + updateTagMuteState(true) + eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) + } else { + Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() + Log.e(TAG, "Failed to mute #$tag") + } }, - { - Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() - Log.e(TAG, "Failed to mute #$tag", it) + { throwable -> + if (throwable is HttpException && throwable.code() == 404) { + mastodonApi.createFilterV1( + tag, + listOf(FilterV1.HOME), + irreversible = false, + wholeWord = true, + expiresInSeconds = null + ).fold( + { filter -> + mutedFilterV1 = filter + updateTagMuteState(true) + eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) + }, + { throwable -> + Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() + Log.e(TAG, "Failed to mute #$tag", throwable) + } + ) + } else { + Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() + Log.e(TAG, "Failed to mute #$tag", throwable) + } } ) } @@ -244,19 +282,49 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { } private fun unmuteTag(): Boolean { - val filter = mutedFilter ?: return true - lifecycleScope.launch { - mastodonApi.deleteFilter(filter.id).fold( + val tag = hashtag + val result = if (mutedFilter != null) { + val filter = mutedFilter!! + if (filter.context.size > 1) { + // This filter exists in multiple contexts, just remove the home context + mastodonApi.updateFilter( + id = filter.id, + context = filter.context.filter { it != Filter.Kind.HOME.kind } + ) + } else { + mastodonApi.deleteFilter(filter.id) + } + } else if (mutedFilterV1 != null) { + mutedFilterV1?.let { filter -> + if (filter.context.size > 1) { + // This filter exists in multiple contexts, just remove the home context + mastodonApi.updateFilterV1( + id = filter.id, + phrase = filter.phrase, + context = filter.context.filter { it != FilterV1.HOME }, + irreversible = null, + wholeWord = null, + expiresInSeconds = null + ) + } else { + mastodonApi.deleteFilterV1(filter.id) + } + } + } else { + null + } + + result?.fold( { - muteTagItem?.isVisible = true - unmuteTagItem?.isVisible = false - eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) + updateTagMuteState(false) + eventHub.dispatch(PreferenceChangedEvent(Filter.Kind.HOME.kind)) + mutedFilterV1 = null mutedFilter = null }, - { - Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, filter.phrase), Snackbar.LENGTH_SHORT).show() - Log.e(TAG, "Failed to unmute #${filter.phrase}", it) + { throwable -> + Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() + Log.e(TAG, "Failed to unmute #$tag", throwable) } ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index 7ea063e1e..75aa76b8b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -20,9 +20,11 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.fragment.app.Fragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment +import com.keylesspalace.tusky.components.notifications.NotificationsFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel -import com.keylesspalace.tusky.fragment.NotificationsFragment +import com.keylesspalace.tusky.components.trending.TrendingFragment +import java.util.Objects /** this would be a good case for a sealed class, but that does not work nice with Room */ @@ -31,6 +33,7 @@ const val NOTIFICATIONS = "Notifications" const val LOCAL = "Local" const val FEDERATED = "Federated" const val DIRECT = "Direct" +const val TRENDING = "Trending" const val HASHTAG = "Hashtag" const val LIST = "List" @@ -44,60 +47,82 @@ data class TabData( val arguments: List = emptyList(), val title: (Context) -> String = { context -> context.getString(text) }, val enableStreaming: Boolean = false, -) +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as TabData + + if (id != other.id) return false + if (arguments != other.arguments) return false + + return true + } + + override fun hashCode() = Objects.hash(id, arguments) +} + +fun List.hasTab(id: String): Boolean = this.find { it.id == id } != null fun createTabDataFromId(id: String, arguments: List = emptyList()): TabData { val enableStreaming = id.endsWith(STREAMING) return when (if (enableStreaming) id.slice(IntRange(0, id.length - 4)) else id) { HOME -> TabData( - HOME, - R.string.title_home, - R.drawable.ic_home_24dp, - { TimelineFragment.newInstance(TimelineViewModel.Kind.HOME, enableStreaming = enableStreaming) }, - enableStreaming = enableStreaming + id = HOME, + text = R.string.title_home, + icon = R.drawable.ic_home_24dp, + fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.HOME, enableStreaming = enableStreaming) }, + enableStreaming = enableStreaming, ) NOTIFICATIONS -> TabData( - NOTIFICATIONS, - R.string.title_notifications, - R.drawable.ic_notifications_24dp, - { NotificationsFragment.newInstance() } + id = NOTIFICATIONS, + text = R.string.title_notifications, + icon = R.drawable.ic_notifications_24dp, + fragment = { NotificationsFragment.newInstance() } ) LOCAL -> TabData( - LOCAL, - R.string.title_public_local, - R.drawable.ic_local_24dp, - { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL, enableStreaming = enableStreaming) }, - enableStreaming = enableStreaming + id = LOCAL, + text = R.string.title_public_local, + icon = R.drawable.ic_local_24dp, + fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL, enableStreaming = enableStreaming) }, + enableStreaming = enableStreaming, ) FEDERATED -> TabData( - FEDERATED, - R.string.title_public_federated, - R.drawable.ic_public_24dp, - { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED, enableStreaming = enableStreaming) }, - enableStreaming = enableStreaming + id = FEDERATED, + text = R.string.title_public_federated, + icon = R.drawable.ic_public_24dp, + fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED, enableStreaming = enableStreaming) }, + enableStreaming = enableStreaming, ) DIRECT -> TabData( - DIRECT, - R.string.title_direct_messages, - R.drawable.ic_reblog_direct_24dp, - { ConversationsFragment.newInstance() } + id = DIRECT, + text = R.string.title_direct_messages, + icon = R.drawable.ic_reblog_direct_24dp, + fragment = { ConversationsFragment.newInstance() } + ) + TRENDING -> TabData( + id = TRENDING, + text = R.string.title_public_trending_hashtags, + icon = R.drawable.ic_trending_up_24px, + fragment = { TrendingFragment.newInstance() } ) HASHTAG -> TabData( - HASHTAG, - R.string.hashtags, - R.drawable.ic_hashtag, - { args -> TimelineFragment.newHashtagInstance(args) }, - arguments, - { context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) } } + id = HASHTAG, + text = R.string.hashtags, + icon = R.drawable.ic_hashtag, + fragment = { args -> TimelineFragment.newHashtagInstance(args) }, + arguments = arguments, + title = { context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) } } ) LIST -> TabData( - LIST, - R.string.list, - R.drawable.ic_list, - { args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty(), true, enableStreaming) }, - arguments, - { arguments.getOrNull(1).orEmpty() }, - enableStreaming + id = LIST, + text = R.string.list, + icon = R.drawable.ic_list, + fragment = { args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty(), enableStreaming = enableStreaming) }, + arguments = arguments, + title = { arguments.getOrNull(1).orEmpty() }, + enableStreaming = enableStreaming, ) else -> throw IllegalArgumentException("unknown tab type") } diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index 79b9f7ec9..cbe86b7cc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -15,16 +15,21 @@ package com.keylesspalace.tusky +import android.content.Intent import android.graphics.Color import android.os.Bundle import android.util.Log +import android.view.Gravity import android.view.View import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.AppCompatEditText import androidx.core.view.updatePadding -import androidx.lifecycle.Lifecycle +import androidx.core.widget.doOnTextChanged import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.ItemTouchHelper @@ -33,8 +38,7 @@ import androidx.recyclerview.widget.RecyclerView import androidx.transition.TransitionManager import at.connyduck.calladapter.networkresult.fold import at.connyduck.sparkbutton.helpers.Utils -import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from -import autodispose2.autoDispose +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 @@ -45,11 +49,16 @@ import com.keylesspalace.tusky.appstore.MainTabsChangedEvent import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.onTextChanged +import com.keylesspalace.tusky.util.getDimension +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.regex.Pattern import javax.inject.Inject @@ -58,6 +67,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene @Inject lateinit var mastodonApi: MastodonApi + @Inject lateinit var eventHub: EventHub @@ -70,9 +80,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene private var tabsChanged = false - private val selectedItemElevation by lazy { resources.getDimension(R.dimen.selected_drag_item_elevation) } + private val selectedItemElevation by unsafeLazy { resources.getDimension(R.dimen.selected_drag_item_elevation) } - private val hashtagRegex by lazy { Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) } + private val hashtagRegex by unsafeLazy { Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) } private val onFabDismissedCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { @@ -160,7 +170,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene } override fun onTabAdded(tab: TabData) { - if (currentTabs.size >= MAX_TAB_COUNT) { return } @@ -222,7 +231,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene } private fun showAddHashtagDialog(tab: TabData? = null, tabPosition: Int = 0) { - val frameLayout = FrameLayout(this) val padding = Utils.dpToPx(this, 8) frameLayout.updatePadding(left = padding, right = padding) @@ -254,7 +262,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene } .create() - editText.onTextChanged { s, _, _, _ -> + editText.doOnTextChanged { s, _, _, _ -> dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(s) } @@ -265,19 +273,30 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene private fun showSelectListDialog() { val adapter = ListSelectionAdapter(this) - lifecycleScope.launch { - mastodonApi.getLists().fold( - { lists -> - adapter.addAll(lists) - }, - { throwable -> - Log.e("TabPreferenceActivity", "failed to load lists", throwable) - } - ) - } - AlertDialog.Builder(this) + val statusLayout = LinearLayout(this) + statusLayout.gravity = Gravity.CENTER + val progress = ProgressBar(this) + val preferredPadding = getDimension(this, androidx.appcompat.R.attr.dialogPreferredPadding) + progress.setPadding(preferredPadding, 0, preferredPadding, 0) + progress.visible(false) + + val noListsText = TextView(this) + noListsText.setPadding(preferredPadding, 0, preferredPadding, 0) + noListsText.text = getText(R.string.select_list_empty) + noListsText.visible(false) + + statusLayout.addView(progress) + statusLayout.addView(noListsText) + + val dialogBuilder = AlertDialog.Builder(this) .setTitle(R.string.select_list_title) + .setNeutralButton(R.string.select_list_manage) { _, _ -> + val listIntent = Intent(applicationContext, ListsActivity::class.java) + startActivity(listIntent) + } + .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)) @@ -286,7 +305,40 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene updateAvailableTabs() saveTabs() } - .show() + + val showProgressBarJob = getProgressBarJob(progress, 500) + showProgressBarJob.start() + + val dialog = dialogBuilder.show() + + lifecycleScope.launch { + mastodonApi.getLists().fold( + { lists -> + showProgressBarJob.cancel() + adapter.addAll(lists) + if (lists.isEmpty()) { + noListsText.show() + } + }, + { throwable -> + dialog.hide() + Log.e("TabPreferenceActivity", "failed to load lists", throwable) + Snackbar.make(binding.root, R.string.error_list_load, Snackbar.LENGTH_LONG).show() + } + ) + } + } + + private fun getProgressBarJob(progressView: View, delayMs: Long) = this.lifecycleScope.launch( + start = CoroutineStart.LAZY + ) { + try { + delay(delayMs) + progressView.show() + awaitCancellation() + } finally { + progressView.hide() + } } private fun validateHashtag(input: CharSequence?): Boolean { @@ -317,6 +369,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene if (!currentTabs.contains(directMessagesTab)) { addableTabs.add(directMessagesTab) } + val trendingTab = createTabDataFromId(TRENDING) + if (!currentTabs.contains(trendingTab)) { + addableTabs.add(trendingTab) + } addableTabs.add(createTabDataFromId(HASHTAG)) addableTabs.add(createTabDataFromId(LIST)) @@ -337,13 +393,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene private fun saveTabs() { accountManager.activeAccount?.let { - Single.fromCallable { + lifecycleScope.launch(Dispatchers.IO) { it.tabPreferences = currentTabs accountManager.saveAccount(it) } - .subscribeOn(Schedulers.io()) - .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe() } tabsChanged = true } @@ -351,7 +404,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene override fun onPause() { super.onPause() if (tabsChanged) { - eventHub.dispatch(MainTabsChangedEvent(currentTabs)) + lifecycleScope.launch { + eventHub.dispatch(MainTabsChangedEvent(currentTabs)) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index 4c7aeca9d..ef5b8cab4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -16,12 +16,14 @@ package com.keylesspalace.tusky import android.app.Application +import android.content.SharedPreferences import android.util.Log -import androidx.preference.PreferenceManager import androidx.work.WorkManager import autodispose2.AutoDisposePlugins import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory 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 @@ -36,7 +38,6 @@ import java.security.Security import javax.inject.Inject class TuskyApplication : Application(), HasAndroidInjector { - @Inject lateinit var androidInjector: DispatchingAndroidInjector @@ -46,6 +47,9 @@ class TuskyApplication : Application(), HasAndroidInjector { @Inject lateinit var localeManager: LocaleManager + @Inject + lateinit var sharedPreferences: SharedPreferences + override fun onCreate() { // Uncomment me to get StrictMode violation logs // if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { @@ -65,7 +69,11 @@ class TuskyApplication : Application(), HasAndroidInjector { AppInjector.init(this) - val preferences = PreferenceManager.getDefaultSharedPreferences(this) + // Migrate shared preference keys and defaults from version to version. + val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, 0) + if (oldVersion != SCHEMA_VERSION) { + upgradeSharedPreferences(oldVersion, SCHEMA_VERSION) + } // In this case, we want to have the emoji preferences merged with the other ones // Copied from PreferenceManager.getDefaultSharedPreferenceName @@ -73,7 +81,7 @@ class TuskyApplication : Application(), HasAndroidInjector { EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false) // init night mode - val theme = preferences.getString("appTheme", APP_THEME_DEFAULT) + val theme = sharedPreferences.getString("appTheme", APP_THEME_DEFAULT) setAppNightMode(theme) localeManager.setLocale() @@ -91,4 +99,24 @@ class TuskyApplication : Application(), HasAndroidInjector { } override fun androidInjector() = androidInjector + + private fun upgradeSharedPreferences(oldVersion: Int, newVersion: Int) { + Log.d(TAG, "Upgrading shared preferences: $oldVersion -> $newVersion") + val editor = sharedPreferences.edit() + + if (oldVersion < 2023022701) { + // These preferences are (now) handled in AccountPreferenceHandler. Remove them from shared for clarity. + + editor.remove(PrefKeys.ALWAYS_OPEN_SPOILER) + editor.remove(PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA) + editor.remove(PrefKeys.MEDIA_PREVIEW_ENABLED) + } + + editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion) + editor.apply() + } + + companion object { + private const val TAG = "TuskyApplication" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt index 8c7dff594..7f81dd9c1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -306,8 +306,9 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener isCreating = false invalidateOptionsMenu() binding.progressBarShare.visibility = View.GONE - if (result) + if (result) { shareFile(file, "image/png") + } }, { error -> isCreating = false diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt index 30cf63097..fc258f083 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt @@ -15,10 +15,9 @@ package com.keylesspalace.tusky.adapter -import android.text.Editable -import android.text.TextWatcher import android.view.LayoutInflater import android.view.ViewGroup +import androidx.core.widget.doAfterTextChanged import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.databinding.ItemEditFieldBinding import com.keylesspalace.tusky.entity.StringField @@ -81,25 +80,13 @@ class AccountFieldEditAdapter : RecyclerView.Adapter + fieldData[holder.bindingAdapterPosition].first = newText.toString() + } - override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} - - override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} - }) - - holder.binding.accountFieldValueText.addTextChangedListener(object : TextWatcher { - override fun afterTextChanged(newText: Editable) { - fieldData[holder.bindingAdapterPosition].second = newText.toString() - } - - override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} - - override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} - }) + holder.binding.accountFieldValueText.doAfterTextChanged { newText -> + fieldData[holder.bindingAdapterPosition].second = newText.toString() + } } class MutableStringPair(var first: String, var second: String) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.kt deleted file mode 100644 index c4da2ab8c..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ -package com.keylesspalace.tusky.adapter - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageButton -import android.widget.ImageView -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.entity.TimelineAccount -import com.keylesspalace.tusky.interfaces.AccountActionListener -import com.keylesspalace.tusky.util.emojify -import com.keylesspalace.tusky.util.loadAvatar - -/** Displays a list of blocked accounts. */ -class BlocksAdapter( - accountActionListener: AccountActionListener, - animateAvatar: Boolean, - animateEmojis: Boolean, - showBotOverlay: Boolean, -) : AccountAdapter( - accountActionListener, - animateAvatar, - animateEmojis, - showBotOverlay -) { - override fun createAccountViewHolder(parent: ViewGroup): BlockedUserViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_blocked_user, parent, false) - return BlockedUserViewHolder(view) - } - - override fun onBindAccountViewHolder(viewHolder: BlockedUserViewHolder, position: Int) { - viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis) - viewHolder.setupActionListener(accountActionListener) - } - - class BlockedUserViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - private val avatar: ImageView = itemView.findViewById(R.id.blocked_user_avatar) - private val username: TextView = itemView.findViewById(R.id.blocked_user_username) - private val displayName: TextView = itemView.findViewById(R.id.blocked_user_display_name) - private val unblock: ImageButton = itemView.findViewById(R.id.blocked_user_unblock) - private var id: String? = null - - fun setupWithAccount(account: TimelineAccount, animateAvatar: Boolean, animateEmojis: Boolean) { - id = account.id - val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis) - displayName.text = emojifiedName - val format = username.context.getString(R.string.post_username_format) - val formattedUsername = String.format(format, account.username) - username.text = formattedUsername - val avatarRadius = avatar.context.resources - .getDimensionPixelSize(R.dimen.avatar_radius_48dp) - loadAvatar(account.avatar, avatar, avatarRadius, animateAvatar) - } - - fun setupActionListener(listener: AccountActionListener) { - unblock.setOnClickListener { - val position = bindingAdapterPosition - if (position != RecyclerView.NO_POSITION) { - listener.onBlock(false, id, position) - } - } - itemView.setOnClickListener { listener.onViewAccount(id) } - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt index 60770ddad..446536e6e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt @@ -21,18 +21,47 @@ import android.text.Spanned import android.text.style.StyleSpan import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter 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 class FollowRequestViewHolder( private val binding: ItemFollowRequestBinding, + private val accountActionListener: AccountActionListener, + private val linkListener: LinkListener, private val showHeader: Boolean -) : RecyclerView.ViewHolder(binding.root) { +) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { + + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + // Skip updates with payloads. That indicates a timestamp update, and + // this view does not have timestamps. + if (!payloads.isNullOrEmpty()) return + + setupWithAccount( + viewData.account, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.animateEmojis, + statusDisplayOptions.showBotOverlay + ) + + setupActionListener(accountActionListener, viewData.account.id) + } fun setupWithAccount( account: TimelineAccount, @@ -41,20 +70,41 @@ class FollowRequestViewHolder( showBotOverlay: Boolean ) { val wrappedName = account.name.unicodeWrap() - val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis) + val emojifiedName: CharSequence = wrappedName.emojify( + account.emojis, + itemView, + animateEmojis + ) binding.displayNameTextView.text = emojifiedName if (showHeader) { - val wholeMessage: String = itemView.context.getString(R.string.notification_follow_request_format, wrappedName) + val wholeMessage: String = itemView.context.getString( + R.string.notification_follow_request_format, + wrappedName + ) binding.notificationTextView.text = SpannableStringBuilder(wholeMessage).apply { - setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + setSpan( + StyleSpan(Typeface.BOLD), + 0, + wrappedName.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) }.emojify(account.emojis, itemView, animateEmojis) } binding.notificationTextView.visible(showHeader) - val format = itemView.context.getString(R.string.post_username_format) - val formattedUsername = String.format(format, account.username) + val formattedUsername = itemView.context.getString(R.string.post_username_format, account.username) binding.usernameTextView.text = formattedUsername + 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) } fun setupActionListener(listener: AccountActionListener, accountId: String) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ListSelectionAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/ListSelectionAdapter.kt index d64784274..7c2a16935 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ListSelectionAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ListSelectionAdapter.kt @@ -26,7 +26,6 @@ import com.keylesspalace.tusky.entity.MastoList class ListSelectionAdapter(context: Context) : ArrayAdapter(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 { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java deleted file mode 100644 index f9a5120a9..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ /dev/null @@ -1,714 +0,0 @@ -/* Copyright 2021 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 . */ - -package com.keylesspalace.tusky.adapter; - -import android.content.Context; -import android.graphics.Color; -import android.graphics.PorterDuff; -import android.graphics.Typeface; -import android.graphics.drawable.Drawable; -import android.text.InputFilter; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.TextUtils; -import android.text.style.StyleSpan; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.ColorRes; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.core.content.ContextCompat; -import androidx.recyclerview.widget.RecyclerView; - -import com.bumptech.glide.Glide; -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding; -import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding; -import com.keylesspalace.tusky.databinding.ViewQuoteInlineBinding; -import com.keylesspalace.tusky.entity.Emoji; -import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.entity.TimelineAccount; -import com.keylesspalace.tusky.interfaces.AccountActionListener; -import com.keylesspalace.tusky.interfaces.LinkListener; -import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; -import com.keylesspalace.tusky.util.CardViewMode; -import com.keylesspalace.tusky.util.CustomEmojiHelper; -import com.keylesspalace.tusky.util.ImageLoadingHelper; -import com.keylesspalace.tusky.util.LinkHelper; -import com.keylesspalace.tusky.util.SmartLengthInputFilter; -import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.util.StringUtils; -import com.keylesspalace.tusky.util.TimestampUtils; -import com.keylesspalace.tusky.viewdata.NotificationViewData; -import com.keylesspalace.tusky.viewdata.StatusViewData; - -import net.accelf.yuito.QuoteInlineHelper; - -import java.util.Date; -import java.util.List; - -import at.connyduck.sparkbutton.helpers.Utils; - -public class NotificationsAdapter extends RecyclerView.Adapter { - - public interface AdapterDataSource { - int getItemCount(); - - T getItemAt(int pos); - } - - - private static final int VIEW_TYPE_STATUS = 0; - private static final int VIEW_TYPE_STATUS_NOTIFICATION = 1; - private static final int VIEW_TYPE_FOLLOW = 2; - private static final int VIEW_TYPE_FOLLOW_REQUEST = 3; - private static final int VIEW_TYPE_PLACEHOLDER = 4; - private static final int VIEW_TYPE_REPORT = 5; - private static final int VIEW_TYPE_UNKNOWN = 6; - - private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; - private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; - - private String accountId; - private StatusDisplayOptions statusDisplayOptions; - private StatusActionListener statusListener; - private NotificationActionListener notificationActionListener; - private AccountActionListener accountActionListener; - private AdapterDataSource dataSource; - private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); - - public NotificationsAdapter(String accountId, - AdapterDataSource dataSource, - StatusDisplayOptions statusDisplayOptions, - StatusActionListener statusListener, - NotificationActionListener notificationActionListener, - AccountActionListener accountActionListener) { - - this.accountId = accountId; - this.dataSource = dataSource; - this.statusDisplayOptions = statusDisplayOptions; - this.statusListener = statusListener; - this.notificationActionListener = notificationActionListener; - this.accountActionListener = accountActionListener; - } - - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - LayoutInflater inflater = LayoutInflater.from(parent.getContext()); - switch (viewType) { - case VIEW_TYPE_STATUS: { - View view = inflater - .inflate(R.layout.item_status, parent, false); - return new StatusViewHolder(view); - } - case VIEW_TYPE_STATUS_NOTIFICATION: { - View view = inflater - .inflate(R.layout.item_status_notification, parent, false); - return new StatusNotificationViewHolder(view, statusDisplayOptions, absoluteTimeFormatter); - } - case VIEW_TYPE_FOLLOW: { - View view = inflater - .inflate(R.layout.item_follow, parent, false); - return new FollowViewHolder(view, statusDisplayOptions); - } - case VIEW_TYPE_FOLLOW_REQUEST: { - ItemFollowRequestBinding binding = ItemFollowRequestBinding.inflate(inflater, parent, false); - return new FollowRequestViewHolder(binding, true); - } - case VIEW_TYPE_PLACEHOLDER: { - View view = inflater - .inflate(R.layout.item_status_placeholder, parent, false); - return new PlaceholderViewHolder(view); - } - case VIEW_TYPE_REPORT: { - ItemReportNotificationBinding binding = ItemReportNotificationBinding.inflate(inflater, parent, false); - return new ReportNotificationViewHolder(binding); - } - default: - case VIEW_TYPE_UNKNOWN: { - View view = new View(parent.getContext()); - view.setLayoutParams( - new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - Utils.dpToPx(parent.getContext(), 24) - ) - ); - return new RecyclerView.ViewHolder(view) { - }; - } - } - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { - bindViewHolder(viewHolder, position, null); - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List payloads) { - bindViewHolder(viewHolder, position, payloads); - } - - private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List payloads) { - Object payloadForHolder = payloads != null && !payloads.isEmpty() ? payloads.get(0) : null; - if (position < this.dataSource.getItemCount()) { - NotificationViewData notification = dataSource.getItemAt(position); - if (notification instanceof NotificationViewData.Placeholder) { - if (payloadForHolder == null) { - NotificationViewData.Placeholder placeholder = ((NotificationViewData.Placeholder) notification); - PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder; - holder.setup(statusListener, placeholder.isLoading()); - } - return; - } - NotificationViewData.Concrete concreteNotification = - (NotificationViewData.Concrete) notification; - switch (viewHolder.getItemViewType()) { - case VIEW_TYPE_STATUS: { - StatusViewHolder holder = (StatusViewHolder) viewHolder; - StatusViewData.Concrete status = concreteNotification.getStatusViewData(); - if (status == null) { - /* in some very rare cases servers sends null status even though they should not, - * we have to handle it somehow */ - holder.showStatusContent(false); - } else { - if (payloads == null) { - holder.showStatusContent(true); - } - holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder); - } - if (concreteNotification.getType() == Notification.Type.POLL) { - holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId())); - } else { - holder.hideStatusInfo(); - } - break; - } - case VIEW_TYPE_STATUS_NOTIFICATION: { - StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder; - StatusViewData.Concrete statusViewData = concreteNotification.getStatusViewData(); - if (payloadForHolder == null) { - if (statusViewData == null) { - /* in some very rare cases servers sends null status even though they should not, - * we have to handle it somehow */ - holder.showNotificationContent(false); - } else { - holder.showNotificationContent(true); - - Status status = statusViewData.getActionable(); - holder.setDisplayName(status.getAccount().getName(), status.getAccount().getEmojis()); - holder.setUsername(status.getAccount().getUsername()); - holder.setCreatedAt(status.getCreatedAt()); - - if (concreteNotification.getType() == Notification.Type.STATUS || - concreteNotification.getType() == Notification.Type.UPDATE) { - holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot()); - } else { - holder.setAvatars(status.getAccount().getAvatar(), - concreteNotification.getAccount().getAvatar()); - } - } - - holder.setMessage(concreteNotification, statusListener); - holder.setupButtons(notificationActionListener, - concreteNotification.getAccount().getId(), - concreteNotification.getId()); - } else { - if (payloadForHolder instanceof List) - for (Object item : (List) payloadForHolder) { - if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) { - holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt()); - } - } - } - break; - } - case VIEW_TYPE_FOLLOW: { - if (payloadForHolder == null) { - FollowViewHolder holder = (FollowViewHolder) viewHolder; - holder.setMessage(concreteNotification.getAccount(), concreteNotification.getType() == Notification.Type.SIGN_UP); - holder.setupButtons(notificationActionListener, concreteNotification.getAccount().getId()); - } - break; - } - case VIEW_TYPE_FOLLOW_REQUEST: { - if (payloadForHolder == null) { - FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder; - holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis(), statusDisplayOptions.showBotOverlay()); - holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId()); - } - break; - } - case VIEW_TYPE_REPORT: { - if (payloadForHolder == null) { - ReportNotificationViewHolder holder = (ReportNotificationViewHolder) viewHolder; - holder.setupWithReport(concreteNotification.getAccount(), concreteNotification.getReport(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis()); - holder.setupActionListener(notificationActionListener, concreteNotification.getReport().getTargetAccount().getId(), concreteNotification.getAccount().getId(), concreteNotification.getReport().getId()); - } - } - default: - } - } - } - - @Override - public int getItemCount() { - return dataSource.getItemCount(); - } - - public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) { - this.statusDisplayOptions = statusDisplayOptions.copy( - statusDisplayOptions.animateAvatars(), - mediaPreviewEnabled, - statusDisplayOptions.useAbsoluteTime(), - statusDisplayOptions.showBotOverlay(), - statusDisplayOptions.useBlurhash(), - CardViewMode.NONE, - statusDisplayOptions.confirmReblogs(), - statusDisplayOptions.confirmFavourites(), - statusDisplayOptions.hideStats(), - statusDisplayOptions.animateEmojis(), - statusDisplayOptions.quoteEnabled() - ); - } - - public boolean isMediaPreviewEnabled() { - return this.statusDisplayOptions.mediaPreviewEnabled(); - } - - @Override - public int getItemViewType(int position) { - NotificationViewData notification = dataSource.getItemAt(position); - if (notification instanceof NotificationViewData.Concrete) { - NotificationViewData.Concrete concrete = ((NotificationViewData.Concrete) notification); - switch (concrete.getType()) { - case MENTION: - case POLL: { - return VIEW_TYPE_STATUS; - } - case STATUS: - case FAVOURITE: - case REBLOG: - case UPDATE: { - return VIEW_TYPE_STATUS_NOTIFICATION; - } - case FOLLOW: - case SIGN_UP: { - return VIEW_TYPE_FOLLOW; - } - case FOLLOW_REQUEST: { - return VIEW_TYPE_FOLLOW_REQUEST; - } - case REPORT: { - return VIEW_TYPE_REPORT; - } - default: { - return VIEW_TYPE_UNKNOWN; - } - } - } else if (notification instanceof NotificationViewData.Placeholder) { - return VIEW_TYPE_PLACEHOLDER; - } else { - throw new AssertionError("Unknown notification type"); - } - - - } - - public interface NotificationActionListener { - void onViewAccount(String id); - - void onViewStatusForNotificationId(String notificationId); - - void onViewReport(String reportId); - - void onExpandedChange(boolean expanded, int position); - - /** - * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long - * status content is interacted with. - * - * @param isCollapsed Whether the status content is shown in a collapsed state or fully. - * @param position The position of the status in the list. - */ - void onNotificationContentCollapsedChange(boolean isCollapsed, int position); - } - - private static class FollowViewHolder extends RecyclerView.ViewHolder { - private TextView message; - private TextView usernameView; - private TextView displayNameView; - private ImageView avatar; - private StatusDisplayOptions statusDisplayOptions; - - FollowViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) { - super(itemView); - message = itemView.findViewById(R.id.notification_text); - usernameView = itemView.findViewById(R.id.notification_username); - displayNameView = itemView.findViewById(R.id.notification_display_name); - avatar = itemView.findViewById(R.id.notification_avatar); - this.statusDisplayOptions = statusDisplayOptions; - } - - void setMessage(TimelineAccount account, Boolean isSignUp) { - Context context = message.getContext(); - - String format = context.getString(isSignUp ? R.string.notification_sign_up_format : R.string.notification_follow_format); - String wrappedDisplayName = StringUtils.unicodeWrap(account.getName()); - String wholeMessage = String.format(format, wrappedDisplayName); - CharSequence emojifiedMessage = CustomEmojiHelper.emojify( - wholeMessage, account.getEmojis(), message, statusDisplayOptions.animateEmojis() - ); - message.setText(emojifiedMessage); - - String username = context.getString(R.string.post_username_format, account.getUsername()); - usernameView.setText(username); - - CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify( - wrappedDisplayName, account.getEmojis(), usernameView, statusDisplayOptions.animateEmojis() - ); - - displayNameView.setText(emojifiedDisplayName); - - int avatarRadius = avatar.getContext().getResources() - .getDimensionPixelSize(R.dimen.avatar_radius_42dp); - - ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, - statusDisplayOptions.animateAvatars()); - - } - - void setupButtons(final NotificationActionListener listener, final String accountId) { - itemView.setOnClickListener(v -> listener.onViewAccount(accountId)); - } - } - - private static class StatusNotificationViewHolder extends RecyclerView.ViewHolder - implements View.OnClickListener { - private final TextView message; - private final View statusNameBar; - private final TextView displayName; - private final TextView username; - private final TextView timestampInfo; - private final TextView statusContent; - private final ImageView statusAvatar; - private final ImageView notificationAvatar; - private final TextView contentWarningDescriptionTextView; - private final Button contentWarningButton; - private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder - private ConstraintLayout quoteContainer; - private StatusDisplayOptions statusDisplayOptions; - private final AbsoluteTimeFormatter absoluteTimeFormatter; - - private String accountId; - private String notificationId; - private NotificationActionListener notificationActionListener; - private StatusViewData.Concrete statusViewData; - - private int avatarRadius48dp; - private int avatarRadius36dp; - private int avatarRadius24dp; - - StatusNotificationViewHolder( - View itemView, - StatusDisplayOptions statusDisplayOptions, - AbsoluteTimeFormatter absoluteTimeFormatter - ) { - super(itemView); - message = itemView.findViewById(R.id.notification_top_text); - statusNameBar = itemView.findViewById(R.id.status_name_bar); - displayName = itemView.findViewById(R.id.status_display_name); - username = itemView.findViewById(R.id.status_username); - timestampInfo = itemView.findViewById(R.id.status_meta_info); - statusContent = itemView.findViewById(R.id.notification_content); - statusAvatar = itemView.findViewById(R.id.notification_status_avatar); - notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar); - contentWarningDescriptionTextView = itemView.findViewById(R.id.notification_content_warning_description); - contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button); - contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content); - this.statusDisplayOptions = statusDisplayOptions; - this.absoluteTimeFormatter = absoluteTimeFormatter; - - quoteContainer = itemView.findViewById(R.id.status_quote_inline_container); - - int darkerFilter = Color.rgb(123, 123, 123); - statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); - notificationAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); - - itemView.setOnClickListener(this); - message.setOnClickListener(this); - statusContent.setOnClickListener(this); - - this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); - this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); - this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); - } - - private void showNotificationContent(boolean show) { - statusNameBar.setVisibility(show ? View.VISIBLE : View.GONE); - contentWarningDescriptionTextView.setVisibility(show ? View.VISIBLE : View.GONE); - contentWarningButton.setVisibility(show ? View.VISIBLE : View.GONE); - statusContent.setVisibility(show ? View.VISIBLE : View.GONE); - statusAvatar.setVisibility(show ? View.VISIBLE : View.GONE); - notificationAvatar.setVisibility(show ? View.VISIBLE : View.GONE); - } - - private void setDisplayName(String name, List emojis) { - CharSequence emojifiedName = CustomEmojiHelper.emojify(name, emojis, displayName, statusDisplayOptions.animateEmojis()); - displayName.setText(emojifiedName); - } - - private void setUsername(String name) { - Context context = username.getContext(); - String format = context.getString(R.string.post_username_format); - String usernameText = String.format(format, name); - username.setText(usernameText); - } - - protected void setCreatedAt(@Nullable Date createdAt) { - if (statusDisplayOptions.useAbsoluteTime()) { - timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true)); - } else { - // This is the visible timestampInfo. - String readout; - /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" - * as 17 meters instead of minutes. */ - CharSequence readoutAloud; - if (createdAt != null) { - long then = createdAt.getTime(); - long now = new Date().getTime(); - readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now); - readoutAloud = android.text.format.DateUtils.getRelativeTimeSpanString(then, now, - android.text.format.DateUtils.SECOND_IN_MILLIS, - android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE); - } else { - // unknown minutes~ - readout = "?m"; - readoutAloud = "? minutes"; - } - timestampInfo.setText(readout); - timestampInfo.setContentDescription(readoutAloud); - } - } - - Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) { - Drawable icon = ContextCompat.getDrawable(context, drawable); - if (icon != null) { - icon.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP); - } - return icon; - } - - void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) { - this.statusViewData = notificationViewData.getStatusViewData(); - - String displayName = StringUtils.unicodeWrap(notificationViewData.getAccount().getName()); - Notification.Type type = notificationViewData.getType(); - - Context context = message.getContext(); - String format; - Drawable icon; - switch (type) { - default: - case FAVOURITE: { - icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange); - format = context.getString(R.string.notification_favourite_format); - break; - } - case REBLOG: { - icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue); - format = context.getString(R.string.notification_reblog_format); - break; - } - case STATUS: { - icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue); - format = context.getString(R.string.notification_subscription_format); - break; - } - case UPDATE: { - icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue); - format = context.getString(R.string.notification_update_format); - break; - } - } - message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null); - String wholeMessage = String.format(format, displayName); - final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage); - int displayNameIndex = format.indexOf("%s"); - str.setSpan( - new StyleSpan(Typeface.BOLD), - displayNameIndex, - displayNameIndex + displayName.length(), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ); - CharSequence emojifiedText = CustomEmojiHelper.emojify( - str, notificationViewData.getAccount().getEmojis(), message, statusDisplayOptions.animateEmojis() - ); - message.setText(emojifiedText); - - if (statusViewData != null) { - boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus().getSpoilerText()); - contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); - contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); - if (statusViewData.isExpanded()) { - contentWarningButton.setText(R.string.post_content_warning_show_less); - } else { - contentWarningButton.setText(R.string.post_content_warning_show_more); - } - - contentWarningButton.setOnClickListener(view -> { - if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { - notificationActionListener.onExpandedChange(!statusViewData.isExpanded(), getBindingAdapterPosition()); - } - statusContent.setVisibility(statusViewData.isExpanded() ? View.GONE : View.VISIBLE); - }); - - setupContentAndSpoiler(listener); - } - - } - - void setupButtons(final NotificationActionListener listener, final String accountId, - final String notificationId) { - this.notificationActionListener = listener; - this.accountId = accountId; - this.notificationId = notificationId; - } - - void setAvatar(@Nullable String statusAvatarUrl, boolean isBot) { - statusAvatar.setPaddingRelative(0, 0, 0, 0); - - ImageLoadingHelper.loadAvatar(statusAvatarUrl, - statusAvatar, avatarRadius48dp, statusDisplayOptions.animateAvatars()); - - if (statusDisplayOptions.showBotOverlay() && isBot) { - notificationAvatar.setVisibility(View.VISIBLE); - Glide.with(notificationAvatar) - .load(ContextCompat.getDrawable(notificationAvatar.getContext(), R.drawable.bot_badge)) - .into(notificationAvatar); - - } else { - notificationAvatar.setVisibility(View.GONE); - } - } - - void setAvatars(@Nullable String statusAvatarUrl, @Nullable String notificationAvatarUrl) { - int padding = Utils.dpToPx(statusAvatar.getContext(), 12); - statusAvatar.setPaddingRelative(0, 0, padding, padding); - - ImageLoadingHelper.loadAvatar(statusAvatarUrl, - statusAvatar, avatarRadius36dp, statusDisplayOptions.animateAvatars()); - - notificationAvatar.setVisibility(View.VISIBLE); - ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar, - avatarRadius24dp, statusDisplayOptions.animateAvatars()); - } - - private void setQuoteContainer(StatusViewData.Concrete quote, final LinkListener listener, StatusDisplayOptions statusDisplayOptions) { - if (quote != null) { - quoteContainer.setVisibility(View.VISIBLE); - ViewQuoteInlineBinding binding = ViewQuoteInlineBinding.bind(quoteContainer); - new QuoteInlineHelper(binding, listener, - quoteContainer.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp), - statusDisplayOptions) - .setupQuoteContainer(quote); - } else { - quoteContainer.setVisibility(View.GONE); - } - } - - @Override - public void onClick(View v) { - switch (v.getId()) { - case R.id.notification_container: - case R.id.notification_content: - if (notificationActionListener != null) - notificationActionListener.onViewStatusForNotificationId(notificationId); - break; - case R.id.notification_top_text: - if (notificationActionListener != null) - notificationActionListener.onViewAccount(accountId); - break; - } - } - - private void setupContentAndSpoiler(final LinkListener listener) { - - boolean shouldShowContentIfSpoiler = statusViewData.isExpanded(); - boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText()); - if (!shouldShowContentIfSpoiler && hasSpoiler) { - statusContent.setVisibility(View.GONE); - } else { - statusContent.setVisibility(View.VISIBLE); - } - - Spanned content = statusViewData.getContent(); - List emojis = statusViewData.getActionable().getEmojis(); - - if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) { - contentCollapseButton.setOnClickListener(view -> { - int position = getBindingAdapterPosition(); - if (position != RecyclerView.NO_POSITION && notificationActionListener != null) { - notificationActionListener.onNotificationContentCollapsedChange(!statusViewData.isCollapsed(), position); - } - }); - - contentCollapseButton.setVisibility(View.VISIBLE); - if (statusViewData.isCollapsed()) { - contentCollapseButton.setText(R.string.post_content_warning_show_more); - statusContent.setFilters(COLLAPSE_INPUT_FILTER); - } else { - contentCollapseButton.setText(R.string.post_content_warning_show_less); - statusContent.setFilters(NO_INPUT_FILTER); - } - } else { - contentCollapseButton.setVisibility(View.GONE); - statusContent.setFilters(NO_INPUT_FILTER); - } - - CharSequence emojifiedText = CustomEmojiHelper.emojify( - content, emojis, statusContent, statusDisplayOptions.animateEmojis() - ); - LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), statusViewData.getActionable().getTags(), listener); - - CharSequence emojifiedContentWarning; - if (statusViewData.getSpoilerText() != null) { - emojifiedContentWarning = CustomEmojiHelper.emojify( - statusViewData.getSpoilerText(), - statusViewData.getActionable().getEmojis(), - contentWarningDescriptionTextView, - statusDisplayOptions.animateEmojis() - ); - } else { - emojifiedContentWarning = ""; - } - contentWarningDescriptionTextView.setText(emojifiedContentWarning); - - setQuoteContainer(statusViewData.getQuoteViewData(), listener, statusDisplayOptions); - } - - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt index 596c9432d..3e4e1dad9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt @@ -75,7 +75,6 @@ class PollAdapter : RecyclerView.Adapter>() { override fun getItemCount() = pollOptions.size override fun onBindViewHolder(holder: BindingHolder, position: Int) { - val option = pollOptions[position] val resultTextView = holder.binding.statusPollOptionResult diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt index db2f79a99..7502c24e9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt @@ -20,28 +20,76 @@ import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import at.connyduck.sparkbutton.helpers.Utils import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.NotificationsAdapter.NotificationActionListener +import com.keylesspalace.tusky.components.notifications.NotificationActionListener +import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding import com.keylesspalace.tusky.entity.Report import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.getRelativeTimeSpanString import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.unicodeWrap +import com.keylesspalace.tusky.viewdata.NotificationViewData import java.util.Date class ReportNotificationViewHolder( private val binding: ItemReportNotificationBinding, -) : RecyclerView.ViewHolder(binding.root) { + private val notificationActionListener: NotificationActionListener +) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { - fun setupWithReport(reporter: TimelineAccount, report: Report, animateAvatar: Boolean, animateEmojis: Boolean) { - val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, itemView, animateEmojis) - val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, itemView, animateEmojis) - val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp) + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + // Skip updates with payloads. That indicates a timestamp update, and + // this view does not have timestamps. + if (!payloads.isNullOrEmpty()) return + + setupWithReport( + viewData.account, + viewData.report!!, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.animateEmojis + ) + setupActionListener( + notificationActionListener, + viewData.report.targetAccount.id, + viewData.account.id, + viewData.report.id + ) + } + + private fun setupWithReport( + reporter: TimelineAccount, + report: Report, + animateAvatar: Boolean, + animateEmojis: Boolean + ) { + val reporterName = reporter.name.unicodeWrap().emojify( + reporter.emojis, + binding.root, + animateEmojis + ) + val reporteeName = report.targetAccount.name.unicodeWrap().emojify( + report.targetAccount.emojis, + itemView, + animateEmojis + ) + val icon = ContextCompat.getDrawable(binding.root.context, R.drawable.ic_flag_24dp) binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null) - binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName) - binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), report.status_ids?.size ?: 0) + binding.notificationTopText.text = itemView.context.getString( + R.string.notification_header_report_format, + reporterName, + reporteeName + ) + binding.notificationSummary.text = itemView.context.getString( + R.string.notification_summary_report_format, + getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), + report.status_ids?.size ?: 0 + ) binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category) // Fancy avatar inset @@ -52,17 +100,22 @@ class ReportNotificationViewHolder( report.targetAccount.avatar, binding.notificationReporteeAvatar, itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp), - animateAvatar, + animateAvatar ) loadAvatar( reporter.avatar, binding.notificationReporterAvatar, itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp), - animateAvatar, + animateAvatar ) } - fun setupActionListener(listener: NotificationActionListener, reporteeId: String, reporterId: String, reportId: String) { + private fun setupActionListener( + listener: NotificationActionListener, + reporteeId: String, + reporterId: String, + reportId: String + ) { binding.notificationReporteeAvatar.setOnClickListener { val position = bindingAdapterPosition if (position != RecyclerView.NO_POSITION) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 6fb16edda..2c9742d17 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -13,6 +13,7 @@ import android.text.TextUtils; import android.text.format.DateUtils; import android.text.style.DynamicDrawableSpan; import android.text.style.ImageSpan; +import android.view.Menu; import android.view.View; import android.view.ViewGroup; import android.widget.Button; @@ -25,12 +26,11 @@ import android.widget.Toast; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.widget.PopupMenu; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.core.content.ContextCompat; import androidx.core.text.HtmlCompat; -import androidx.core.view.ViewKt; import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -50,6 +50,8 @@ import com.keylesspalace.tusky.entity.Attachment.Focus; import com.keylesspalace.tusky.entity.Attachment.MetaData; import com.keylesspalace.tusky.entity.Card; import com.keylesspalace.tusky.entity.Emoji; +import com.keylesspalace.tusky.entity.Filter; +import com.keylesspalace.tusky.entity.FilterResult; import com.keylesspalace.tusky.entity.HashTag; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; @@ -59,6 +61,7 @@ import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.util.NumberUtils; import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.TimestampUtils; import com.keylesspalace.tusky.util.TouchDelegateHelper; @@ -84,49 +87,55 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { public static final String KEY_CREATED = "created"; } - private TextView displayName; - private TextView username; - private ImageButton replyButton; - private TextView replyCountLabel; - private SparkButton reblogButton; - private SparkButton favouriteButton; - private ImageButton quoteButton; - private SparkButton bookmarkButton; - private ImageButton moreButton; - private ConstraintLayout mediaContainer; - protected MediaPreviewLayout mediaPreview; - private TextView sensitiveMediaWarning; - private View sensitiveMediaShow; - protected TextView[] mediaLabels; - protected CharSequence[] mediaDescriptions; - private MaterialButton contentWarningButton; - private ImageView avatarInset; + private final String TAG = "StatusBaseViewHolder"; - public ImageView avatar; - public TextView metaInfo; - public TextView content; - public TextView contentWarningDescription; + private final TextView displayName; + private final TextView username; + private final ImageButton replyButton; + private final TextView replyCountLabel; + private final SparkButton reblogButton; + private final SparkButton favouriteButton; + private final ImageButton quoteButton; + private final SparkButton bookmarkButton; + private final ImageButton moreButton; + private final ConstraintLayout mediaContainer; + protected final MediaPreviewLayout mediaPreview; + private final TextView sensitiveMediaWarning; + private final View sensitiveMediaShow; + protected final TextView[] mediaLabels; + protected final CharSequence[] mediaDescriptions; + private final MaterialButton contentWarningButton; + private final ImageView avatarInset; - private ConstraintLayout quoteContainer; + private final ConstraintLayout quoteContainer; - private RecyclerView pollOptions; - private TextView pollDescription; - private Button pollButton; + public final ImageView avatar; + public final TextView metaInfo; + public final TextView content; + public final TextView contentWarningDescription; - private LinearLayout cardView; - private LinearLayout cardInfo; - private ShapeableImageView cardImage; - private TextView cardTitle; - private TextView cardDescription; - private TextView cardUrl; - private PollAdapter pollAdapter; + private final RecyclerView pollOptions; + private final TextView pollDescription; + private final Button pollButton; + + private final LinearLayout cardView; + private final LinearLayout cardInfo; + private final ShapeableImageView cardImage; + private final TextView cardTitle; + private final TextView cardDescription; + private final TextView cardUrl; + private final PollAdapter pollAdapter; + protected LinearLayout filteredPlaceholder; + protected TextView filteredPlaceholderLabel; + protected Button filteredPlaceholderShowButton; + protected ConstraintLayout statusContainer; private final NumberFormat numberFormat = NumberFormat.getNumberInstance(); private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); - protected int avatarRadius48dp; - private int avatarRadius36dp; - private int avatarRadius24dp; + protected final int avatarRadius48dp; + private final int avatarRadius36dp; + private final int avatarRadius24dp; private final Drawable mediaPreviewUnloaded; @@ -175,6 +184,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { cardDescription = itemView.findViewById(R.id.card_description); cardUrl = itemView.findViewById(R.id.card_link); + filteredPlaceholder = itemView.findViewById(R.id.status_filtered_placeholder); + filteredPlaceholderLabel = itemView.findViewById(R.id.status_filter_label); + filteredPlaceholderShowButton = itemView.findViewById(R.id.status_filter_show_anyway); + statusContainer = itemView.findViewById(R.id.status_container); + pollAdapter = new PollAdapter(); pollOptions.setAdapter(pollAdapter); pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext())); @@ -206,16 +220,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { contentWarningButton.performClick(); } - protected void setSpoilerAndContent(boolean expanded, - @NonNull Spanned content, - @Nullable String spoilerText, - @Nullable List mentions, - @Nullable List tags, - @NonNull List emojis, - @Nullable PollViewData poll, + protected void setSpoilerAndContent(@NonNull StatusViewData.Concrete status, @NonNull StatusDisplayOptions statusDisplayOptions, final StatusActionListener listener) { + + Status actionable = status.getActionable(); + String spoilerText = status.getSpoilerText(); + List emojis = actionable.getEmojis(); + boolean sensitive = !TextUtils.isEmpty(spoilerText); + boolean expanded = status.isExpanded(); + if (sensitive) { CharSequence emojiSpoiler = CustomEmojiHelper.emojify( spoilerText, emojis, contentWarningDescription, statusDisplayOptions.animateEmojis() @@ -224,20 +239,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { contentWarningDescription.setVisibility(View.VISIBLE); contentWarningButton.setVisibility(View.VISIBLE); setContentWarningButtonText(expanded); - contentWarningButton.setOnClickListener(view -> { - contentWarningDescription.invalidate(); - if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { - listener.onExpandedChange(!expanded, getBindingAdapterPosition()); - } - setContentWarningButtonText(!expanded); - - this.setTextVisible(sensitive, !expanded, content, mentions, tags, emojis, poll, statusDisplayOptions, listener); - }); - this.setTextVisible(sensitive, expanded, content, mentions, tags, emojis, poll, statusDisplayOptions, listener); + contentWarningButton.setOnClickListener(view -> toggleExpandedState(true, !expanded, status, statusDisplayOptions, listener)); + this.setTextVisible(true, expanded, status, statusDisplayOptions, listener); } else { contentWarningDescription.setVisibility(View.GONE); contentWarningButton.setVisibility(View.GONE); - this.setTextVisible(sensitive, true, content, mentions, tags, emojis, poll, statusDisplayOptions, listener); + this.setTextVisible(false, true, status, statusDisplayOptions, listener); } } @@ -249,20 +256,42 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } + protected void toggleExpandedState(boolean sensitive, + boolean expanded, + @NonNull final StatusViewData.Concrete status, + @NonNull final StatusDisplayOptions statusDisplayOptions, + @NonNull final StatusActionListener listener) { + + contentWarningDescription.invalidate(); + int adapterPosition = getBindingAdapterPosition(); + if (adapterPosition != RecyclerView.NO_POSITION) { + listener.onExpandedChange(expanded, adapterPosition); + } + setContentWarningButtonText(expanded); + + this.setTextVisible(sensitive, expanded, status, statusDisplayOptions, listener); + + setupCard(status, expanded, statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); + } + private void setTextVisible(boolean sensitive, boolean expanded, - Spanned content, - List mentions, - List tags, - List emojis, - @Nullable PollViewData poll, - StatusDisplayOptions statusDisplayOptions, + @NonNull final StatusViewData.Concrete status, + @NonNull final StatusDisplayOptions statusDisplayOptions, final StatusActionListener listener) { + + Status actionable = status.getActionable(); + Spanned content = status.getContent(); + List mentions = actionable.getMentions(); + List tags =actionable.getTags(); + List emojis = actionable.getEmojis(); + PollViewData poll = PollViewDataKt.toViewData(actionable.getPoll()); + if (expanded) { CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis()); LinkHelper.setClickableText(this.content, emojifiedText, mentions, tags, listener); for (int i = 0; i < mediaLabels.length; ++i) { - updateMediaLabel(i, sensitive, expanded); + updateMediaLabel(i, sensitive, true); } if (poll != null) { setupPoll(poll, emojis, statusDisplayOptions, listener); @@ -287,7 +316,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } private void setAvatar(String url, - @Nullable String rebloggedUrl, + @Nullable String rebloggedUrl, boolean isBot, StatusDisplayOptions statusDisplayOptions) { @@ -298,8 +327,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (statusDisplayOptions.showBotOverlay() && isBot) { avatarInset.setVisibility(View.VISIBLE); Glide.with(avatarInset) - // passing the drawable id directly into .load() ignores night mode https://github.com/bumptech/glide/issues/4692 - .load(ContextCompat.getDrawable(avatarInset.getContext(), R.drawable.bot_badge)) + .load(R.drawable.bot_badge) .into(avatarInset); } else { avatarInset.setVisibility(View.GONE); @@ -356,8 +384,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } else { long then = createdAt.getTime(); long now = System.currentTimeMillis(); - String readout = TimestampUtils.getRelativeTimeSpanString(metaInfo.getContext(), then, now); - timestampText = readout; + timestampText = TimestampUtils.getRelativeTimeSpanString(metaInfo.getContext(), then, now); } } @@ -444,11 +471,18 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } - private void setReplyCount(int repliesCount) { + protected void setReplyCount(int repliesCount, boolean fullStats) { // This label only exists in the non-detailed view (to match the web ui) - if (replyCountLabel != null) { - replyCountLabel.setText((repliesCount > 1 ? replyCountLabel.getContext().getString(R.string.status_count_one_plus) : Integer.toString(repliesCount))); + if (replyCountLabel == null) return; + + if (fullStats) { + replyCountLabel.setText(NumberUtils.shortNumber(repliesCount)); + return; } + + // Show "0", "1", or "1+" for replies otherwise, so the user knows if there is a thread + // that they can click through to read. + replyCountLabel.setText((repliesCount > 1 ? replyCountLabel.getContext().getString(R.string.status_count_one_plus) : Integer.toString(repliesCount))); } private void setReblogged(boolean reblogged) { @@ -727,7 +761,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { if (statusDisplayOptions.confirmReblogs()) { - showConfirmReblogDialog(listener, statusContent, buttonState, position); + showConfirmReblog(listener, buttonState, position); return false; } else { listener.onReblog(!buttonState, position); @@ -739,12 +773,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { }); } + favouriteButton.setEventListener((button, buttonState) -> { // return true to play animation int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { if (statusDisplayOptions.confirmFavourites()) { - showConfirmFavouriteDialog(listener, statusContent, buttonState, position); + showConfirmFavourite(listener, buttonState, position); return false; } else { listener.onFavourite(!buttonState, position); @@ -801,38 +836,46 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { itemView.setOnClickListener(viewThreadListener); } - private void showConfirmReblogDialog(StatusActionListener listener, - String statusContent, - boolean buttonState, - int position) { - int okButtonTextId = buttonState ? R.string.action_unreblog : R.string.action_reblog; - new AlertDialog.Builder(reblogButton.getContext()) - .setMessage(statusContent) - .setPositiveButton(okButtonTextId, (__, ___) -> { - listener.onReblog(!buttonState, position); - if (!buttonState) { - // Play animation only when it's reblog, not unreblog - reblogButton.playAnimation(); - } - }) - .show(); + private void showConfirmReblog(StatusActionListener listener, + boolean buttonState, + int position) { + PopupMenu popup = new PopupMenu(itemView.getContext(), reblogButton); + popup.inflate(R.menu.status_reblog); + Menu menu = popup.getMenu(); + if (buttonState) { + menu.findItem(R.id.menu_action_reblog).setVisible(false); + } else { + menu.findItem(R.id.menu_action_unreblog).setVisible(false); + } + popup.setOnMenuItemClickListener(item -> { + listener.onReblog(!buttonState, position); + if(!buttonState) { + reblogButton.playAnimation(); + } + return true; + }); + popup.show(); } - private void showConfirmFavouriteDialog(StatusActionListener listener, - String statusContent, - boolean buttonState, - int position) { - int okButtonTextId = buttonState ? R.string.action_unfavourite : R.string.action_favourite; - new AlertDialog.Builder(favouriteButton.getContext()) - .setMessage(statusContent) - .setPositiveButton(okButtonTextId, (__, ___) -> { - listener.onFavourite(!buttonState, position); - if (!buttonState) { - // Play animation only when it's favourite, not unfavourite - favouriteButton.playAnimation(); - } - }) - .show(); + private void showConfirmFavourite(StatusActionListener listener, + boolean buttonState, + int position) { + PopupMenu popup = new PopupMenu(itemView.getContext(), favouriteButton); + popup.inflate(R.menu.status_favourite); + Menu menu = popup.getMenu(); + if (buttonState) { + menu.findItem(R.id.menu_action_favourite).setVisible(false); + } else { + menu.findItem(R.id.menu_action_unfavourite).setVisible(false); + } + popup.setOnMenuItemClickListener(item -> { + listener.onFavourite(!buttonState, position); + if(!buttonState) { + favouriteButton.playAnimation(); + } + return true; + }); + popup.show(); } public void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener, @@ -850,7 +893,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { setUsername(status.getUsername()); setMetaData(status, statusDisplayOptions, listener); setIsReply(actionable.getInReplyToId() != null); - setReplyCount(actionable.getRepliesCount()); + setReplyCount(actionable.getRepliesCount(), statusDisplayOptions.showStatsInline()); setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(), actionable.getAccount().getBot(), statusDisplayOptions); setReblogged(actionable.getReblogged()); @@ -876,19 +919,16 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { hideSensitiveMediaWarning(); } - if (cardView != null) { - setupCard(status, statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); - } + setupCard(status, status.isExpanded(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(), actionable.isNotestock(), statusDisplayOptions); setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility()); setQuoteEnabled(actionable.rebloggingAllowed() && !actionable.isNotestock(), actionable.getVisibility()); - setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), - actionable.getMentions(), actionable.getTags(), actionable.getEmojis(), - PollViewDataKt.toViewData(actionable.getPoll()), statusDisplayOptions, - listener); + setSpoilerAndContent(status, statusDisplayOptions, listener); + + setupFilterPlaceholder(status, listener, statusDisplayOptions); setDescriptionForStatus(status, statusDisplayOptions); @@ -909,6 +949,30 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } + private void setupFilterPlaceholder(StatusViewData.Concrete status, StatusActionListener listener, StatusDisplayOptions displayOptions) { + if (status.getFilterAction() != Filter.Action.WARN) { + showFilteredPlaceholder(false); + return; + } + + showFilteredPlaceholder(true); + + Filter matchedFilter = null; + + for (FilterResult result : status.getActionable().getFiltered()) { + Filter filter = result.getFilter(); + if (filter.getAction() == Filter.Action.WARN) { + matchedFilter = filter; + break; + } + } + + filteredPlaceholderLabel.setText(itemView.getContext().getString(R.string.status_filter_placeholder_label_format, matchedFilter.getTitle())); + filteredPlaceholderShowButton.setOnClickListener(view -> { + listener.clearWarningAction(getBindingAdapterPosition()); + }); + } + protected static boolean hasPreviewableAttachment(List attachments) { for (Attachment attachment : attachments) { if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) { @@ -1143,20 +1207,27 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } protected void setupCard( - StatusViewData.Concrete status, - CardViewMode cardViewMode, - StatusDisplayOptions statusDisplayOptions, + final StatusViewData.Concrete status, + boolean expanded, + final CardViewMode cardViewMode, + final StatusDisplayOptions statusDisplayOptions, final StatusActionListener listener ) { + if (cardView == null) { + return; + } + final Status actionable = status.getActionable(); final Card card = actionable.getCard(); + if (cardViewMode != CardViewMode.NONE && - actionable.getAttachments().size() == 0 && - actionable.getPoll() == null && - card != null && - !TextUtils.isEmpty(card.getUrl()) && - (!actionable.getSensitive() || status.isExpanded()) && - (!status.isCollapsible() || !status.isCollapsed())) { + actionable.getAttachments().size() == 0 && + actionable.getPoll() == null && + card != null && + !TextUtils.isEmpty(card.getUrl()) && + (!actionable.getSensitive() || expanded) && + (!status.isCollapsible() || !status.isCollapsed())) { + cardView.setVisibility(View.VISIBLE); cardTitle.setText(card.getTitle()); if (TextUtils.isEmpty(card.getDescription()) && TextUtils.isEmpty(card.getAuthorName())) { @@ -1249,7 +1320,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { cardImage.setScaleType(ImageView.ScaleType.CENTER); Glide.with(cardImage.getContext()) - .load(ContextCompat.getDrawable(cardImage.getContext(), R.drawable.card_image_placeholder)) + .load(R.drawable.card_image_placeholder) .into(cardImage); } @@ -1288,4 +1359,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { bookmarkButton.setVisibility(visibility); moreButton.setVisibility(visibility); } + + public void showFilteredPlaceholder(boolean show) { + if (statusContainer != null) { + statusContainer.setVisibility(show ? View.GONE : View.VISIBLE); + } + if (filteredPlaceholder != null) { + filteredPlaceholder.setVisibility(show ? View.VISIBLE : View.GONE); + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index d21021714..db99dc0a1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -160,7 +160,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { status; super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads); - setupCard(uncollapsedStatus, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status + setupCard(uncollapsedStatus, status.isExpanded(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status if (payloads == null) { Status actionable = uncollapsedStatus.getActionable(); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index 76d129170..0763b96ae 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -28,9 +28,11 @@ import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.entity.Emoji; +import com.keylesspalace.tusky.entity.Filter; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.CustomEmojiHelper; +import com.keylesspalace.tusky.util.NumberUtils; import com.keylesspalace.tusky.util.SmartLengthInputFilter; import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.StringUtils; @@ -44,13 +46,17 @@ public class StatusViewHolder extends StatusBaseViewHolder { private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; - private TextView statusInfo; - private Button contentCollapseButton; + private final TextView statusInfo; + private final Button contentCollapseButton; + private final TextView favouritedCountLabel; + private final TextView reblogsCountLabel; public StatusViewHolder(View itemView) { super(itemView); statusInfo = itemView.findViewById(R.id.status_info); contentCollapseButton = itemView.findViewById(R.id.button_toggle_content); + favouritedCountLabel = itemView.findViewById(R.id.status_favourites_count); + reblogsCountLabel = itemView.findViewById(R.id.status_insets); } @Override @@ -60,10 +66,13 @@ public class StatusViewHolder extends StatusBaseViewHolder { @Nullable Object payloads) { if (payloads == null) { - setupCollapsedState(status, listener); + boolean sensitive = !TextUtils.isEmpty(status.getActionable().getSpoilerText()); + boolean expanded = status.isExpanded(); + + setupCollapsedState(sensitive, expanded, status, listener); Status reblogging = status.getRebloggingStatus(); - if (reblogging == null) { + if (reblogging == null || status.getFilterAction() == Filter.Action.WARN) { hideStatusInfo(); } else { String rebloggedByDisplayName = reblogging.getAccount().getName(); @@ -73,8 +82,13 @@ public class StatusViewHolder extends StatusBaseViewHolder { } } - super.setupWithStatus(status, listener, statusDisplayOptions, payloads); + reblogsCountLabel.setVisibility(statusDisplayOptions.showStatsInline() ? View.VISIBLE : View.INVISIBLE); + favouritedCountLabel.setVisibility(statusDisplayOptions.showStatsInline() ? View.VISIBLE : View.INVISIBLE); + setFavouritedCount(status.getActionable().getFavouritesCount()); + setReblogsCount(status.getActionable().getReblogsCount()); + + super.setupWithStatus(status, listener, statusDisplayOptions, payloads); } private void setRebloggedByDisplayName(final CharSequence name, @@ -91,7 +105,7 @@ public class StatusViewHolder extends StatusBaseViewHolder { } // don't use this on the same ViewHolder as setRebloggedByDisplayName, will cause recycling issues as paddings are changed - void setPollInfo(final boolean ownPoll) { + protected void setPollInfo(final boolean ownPoll) { statusInfo.setText(ownPoll ? R.string.poll_ended_created : R.string.poll_ended_voted); statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0); statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.getContext(), 10)); @@ -99,13 +113,24 @@ public class StatusViewHolder extends StatusBaseViewHolder { statusInfo.setVisibility(View.VISIBLE); } - void hideStatusInfo() { + protected void setReblogsCount(int reblogsCount) { + reblogsCountLabel.setText(NumberUtils.shortNumber(reblogsCount)); + } + + protected void setFavouritedCount(int favouritedCount) { + favouritedCountLabel.setText(NumberUtils.shortNumber(favouritedCount)); + } + + protected void hideStatusInfo() { statusInfo.setVisibility(View.GONE); } - private void setupCollapsedState(final StatusViewData.Concrete status, final StatusActionListener listener) { + private void setupCollapsedState(boolean sensitive, + boolean expanded, + final StatusViewData.Concrete status, + final StatusActionListener listener) { /* input filter for TextViews have to be set before text */ - if (status.isCollapsible() && (status.isExpanded() || TextUtils.isEmpty(status.getSpoilerText()))) { + if (status.isCollapsible() && (!sensitive || expanded)) { contentCollapseButton.setOnClickListener(view -> { int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) @@ -130,4 +155,16 @@ public class StatusViewHolder extends StatusBaseViewHolder { super.showStatusContent(show); contentCollapseButton.setVisibility(show ? View.VISIBLE : View.GONE); } + + @Override + protected void toggleExpandedState(boolean sensitive, + boolean expanded, + @NonNull StatusViewData.Concrete status, + @NonNull StatusDisplayOptions statusDisplayOptions, + @NonNull final StatusActionListener listener) { + + setupCollapsedState(sensitive, expanded, status, listener); + + super.toggleExpandedState(sensitive, expanded, status, statusDisplayOptions, listener); + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingDateViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingDateViewHolder.kt new file mode 100644 index 000000000..481573068 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingDateViewHolder.kt @@ -0,0 +1,41 @@ +/* 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 . */ + +package com.keylesspalace.tusky.adapter + +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemTrendingDateBinding +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +class TrendingDateViewHolder( + private val binding: ItemTrendingDateBinding +) : RecyclerView.ViewHolder(binding.root) { + + private val dateFormat = SimpleDateFormat("EEE dd MMM yyyy", Locale.getDefault()).apply { + this.timeZone = TimeZone.getDefault() + } + + fun setup(start: Date, end: Date) { + binding.dates.text = itemView.context.getString( + R.string.date_range, + dateFormat.format(start), + dateFormat.format(end) + ) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingTagViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingTagViewHolder.kt new file mode 100644 index 000000000..f852c46b8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingTagViewHolder.kt @@ -0,0 +1,94 @@ +/* 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 . */ + +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, 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]) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt index 66ae898b6..9515457b3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt @@ -3,45 +3,51 @@ package com.keylesspalace.tusky.appstore import com.google.gson.Gson import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase -import io.reactivex.rxjava3.disposables.Disposable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import javax.inject.Inject class CacheUpdater @Inject constructor( eventHub: EventHub, - private val accountManager: AccountManager, + accountManager: AccountManager, appDatabase: AppDatabase, gson: Gson ) { - private val disposable: Disposable + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) init { val timelineDao = appDatabase.timelineDao() - disposable = eventHub.events.subscribe { event -> - val accountId = accountManager.activeAccount?.id ?: return@subscribe - when (event) { - is FavoriteEvent -> - timelineDao.setFavourited(accountId, event.statusId, event.favourite) - is ReblogEvent -> - timelineDao.setReblogged(accountId, event.statusId, event.reblog) - is BookmarkEvent -> - timelineDao.setBookmarked(accountId, event.statusId, event.bookmark) - is UnfollowEvent -> - timelineDao.removeAllByUser(accountId, event.accountId) - is StatusDeletedEvent -> - timelineDao.delete(accountId, event.statusId) - is PollVoteEvent -> { - val pollString = gson.toJson(event.poll) - timelineDao.setVoted(accountId, event.statusId, pollString) + scope.launch { + eventHub.events.collect { event -> + val accountId = accountManager.activeAccount?.id ?: return@collect + when (event) { + is FavoriteEvent -> + timelineDao.setFavourited(accountId, event.statusId, event.favourite) + is ReblogEvent -> + timelineDao.setReblogged(accountId, event.statusId, event.reblog) + is BookmarkEvent -> + timelineDao.setBookmarked(accountId, event.statusId, event.bookmark) + is UnfollowEvent -> + timelineDao.removeAllByUser(accountId, event.accountId) + is StatusDeletedEvent -> + timelineDao.delete(accountId, event.statusId) + is PollVoteEvent -> { + val pollString = gson.toJson(event.poll) + timelineDao.setVoted(accountId, event.statusId, pollString) + } + is PinEvent -> + timelineDao.setPinned(accountId, event.statusId, event.pinned) } - is PinEvent -> - timelineDao.setPinned(accountId, event.statusId, event.pinned) } } } fun stop() { - this.disposable.dispose() + this.scope.cancel() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt index c30536d0e..5e673bcfd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -6,22 +6,23 @@ import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import net.accelf.yuito.streaming.Subscription -data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Dispatchable -data class ReblogEvent(val statusId: String, val reblog: Boolean) : Dispatchable -data class BookmarkEvent(val statusId: String, val bookmark: Boolean) : Dispatchable -data class MuteConversationEvent(val statusId: String, val mute: Boolean) : Dispatchable -data class UnfollowEvent(val accountId: String) : Dispatchable -data class BlockEvent(val accountId: String) : Dispatchable -data class MuteEvent(val accountId: String) : Dispatchable -data class StatusDeletedEvent(val statusId: String) : Dispatchable -data class StatusComposedEvent(val status: Status) : Dispatchable -data class StatusScheduledEvent(val status: Status) : Dispatchable -data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable -data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable -data class MainTabsChangedEvent(val newTabs: List) : Dispatchable -data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable -data class DomainMuteEvent(val instance: String) : Dispatchable -data class AnnouncementReadEvent(val announcementId: String) : Dispatchable -data class PinEvent(val statusId: String, val pinned: Boolean) : Dispatchable -data class QuickReplyEvent(val status: Status) : Dispatchable -data class StreamUpdateEvent(val status: Status, val subscription: Subscription) : Dispatchable +data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Event +data class ReblogEvent(val statusId: String, val reblog: Boolean) : Event +data class BookmarkEvent(val statusId: String, val bookmark: Boolean) : Event +data class MuteConversationEvent(val statusId: String, val mute: Boolean) : Event +data class UnfollowEvent(val accountId: String) : Event +data class BlockEvent(val accountId: String) : Event +data class MuteEvent(val accountId: String) : Event +data class StatusDeletedEvent(val statusId: String) : Event +data class StatusComposedEvent(val status: Status) : Event +data class StatusScheduledEvent(val status: Status) : Event +data class StatusEditedEvent(val originalId: String, val status: Status) : Event +data class ProfileEditedEvent(val newProfileData: Account) : Event +data class PreferenceChangedEvent(val preferenceKey: String) : Event +data class MainTabsChangedEvent(val newTabs: List) : Event +data class PollVoteEvent(val statusId: String, val poll: Poll) : Event +data class DomainMuteEvent(val instance: String) : Event +data class AnnouncementReadEvent(val announcementId: String) : Event +data class PinEvent(val statusId: String, val pinned: Boolean) : Event +data class QuickReplyEvent(val status: Status) : Event +data class StreamUpdateEvent(val status: Status, val subscription: Subscription) : Event diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt index 7fb1f05b8..4030b116f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt @@ -1,20 +1,19 @@ package com.keylesspalace.tusky.appstore -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.subjects.PublishSubject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import javax.inject.Inject import javax.inject.Singleton interface Event -interface Dispatchable : Event @Singleton class EventHub @Inject constructor() { - private val eventsSubject = PublishSubject.create() - val events: Observable = eventsSubject + private val sharedEventFlow: MutableSharedFlow = MutableSharedFlow() + val events: Flow = sharedEventFlow - fun dispatch(event: Dispatchable) { - eventsSubject.onNext(event) + suspend fun dispatch(event: Event) { + sharedEventFlow.emit(event) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt index a53c03951..23edd5e25 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt @@ -22,9 +22,11 @@ import android.content.Context import android.content.Intent import android.content.res.ColorStateList import android.graphics.Color +import android.graphics.drawable.LayerDrawable import android.os.Bundle -import android.text.Editable +import android.text.TextWatcher import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup @@ -32,12 +34,15 @@ import androidx.activity.viewModels import androidx.annotation.ColorInt import androidx.annotation.Px import androidx.appcompat.app.AlertDialog +import androidx.appcompat.content.res.AppCompatResources import androidx.core.app.ActivityOptionsCompat +import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.updatePadding +import androidx.core.widget.doAfterTextChanged import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.viewpager2.widget.MarginPageTransformer @@ -50,13 +55,13 @@ import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.snackbar.Snackbar import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator -import com.keylesspalace.tusky.AccountListActivity import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.EditProfileActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment +import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.databinding.ActivityAccountBinding @@ -70,7 +75,6 @@ import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.DefaultTextWatcher import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Success @@ -82,9 +86,14 @@ import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.reduceSwipeSensitivity import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.view.showMuteAccountDialog +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 dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import java.text.NumberFormat @@ -94,13 +103,14 @@ import java.util.Locale import javax.inject.Inject import kotlin.math.abs -class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, LinkListener { +class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider, HasAndroidInjector, LinkListener { @Inject lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector @Inject lateinit var viewModelFactory: ViewModelFactory + @Inject lateinit var draftsAlert: DraftsAlert @@ -110,7 +120,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI private lateinit var accountFieldAdapter: AccountFieldAdapter - private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) } + private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) } private var followState: FollowState = FollowState.NOT_FOLLOWING private var blocking: Boolean = false @@ -150,11 +160,14 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI private lateinit var adapter: AccountPagerAdapter + private var noteWatcher: TextWatcher? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) loadResources() makeNotificationBarTransparent() setContentView(binding.root) + addMenuProvider(this) // Obtain information to fill out the profile. viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!) @@ -183,9 +196,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI * Load colors and dimensions from resources */ private fun loadResources() { - toolbarColor = MaterialColors.getColor(this, R.attr.colorSurface, Color.BLACK) + toolbarColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, Color.BLACK) statusBarColorTransparent = getColor(R.color.transparent_statusbar_background) - statusBarColorOpaque = MaterialColors.getColor(this, R.attr.colorPrimaryDark, Color.BLACK) + statusBarColorOpaque = MaterialColors.getColor(this, androidx.appcompat.R.attr.colorPrimaryDark, Color.BLACK) avatarSize = resources.getDimension(R.dimen.account_activity_avatar_size) titleVisibleHeight = resources.getDimensionPixelSize(R.dimen.account_activity_scroll_title_visible_height) } @@ -303,6 +316,23 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI toolbarBackground.fillColor = ColorStateList.valueOf(Color.TRANSPARENT) binding.accountToolbar.background = toolbarBackground + // Provide a non-transparent background to the navigation and overflow icons to ensure + // they remain visible over whatever the profile background image might be. + val backgroundCircle = AppCompatResources.getDrawable(this, R.drawable.background_circle)!! + backgroundCircle.alpha = 210 // Any lower than this and the backgrounds interfere + binding.accountToolbar.navigationIcon = LayerDrawable( + arrayOf( + backgroundCircle, + binding.accountToolbar.navigationIcon + ) + ) + binding.accountToolbar.overflowIcon = LayerDrawable( + arrayOf( + backgroundCircle, + binding.accountToolbar.overflowIcon + ) + ) + binding.accountHeaderInfoContainer.background = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation) val avatarBackground = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation).apply { @@ -318,7 +348,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI binding.accountAppBarLayout.addOnOffsetChangedListener(object : AppBarLayout.OnOffsetChangedListener { override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { - if (verticalOffset == oldOffset) { return } @@ -399,14 +428,16 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI draftsAlert.observeInContext(this, true) } + private fun onRefresh() { + viewModel.refresh() + adapter.refreshContent() + } + /** * Setup swipe to refresh layout */ private fun setupRefreshLayout() { - binding.swipeToRefreshLayout.setOnRefreshListener { - viewModel.refresh() - adapter.refreshContent() - } + binding.swipeToRefreshLayout.setOnRefreshListener { onRefresh() } viewModel.isRefreshing.observe( this ) { isRefreshing -> @@ -439,8 +470,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis) setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this) - accountFieldAdapter.fields = account.fields ?: emptyList() - accountFieldAdapter.emojis = account.emojis ?: emptyList() + accountFieldAdapter.fields = account.fields.orEmpty() + accountFieldAdapter.emojis = account.emojis.orEmpty() accountFieldAdapter.notifyDataSetChanged() binding.accountLockedImageView.visible(account.locked) @@ -493,18 +524,23 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI .centerCrop() .into(binding.accountHeaderImageView) - binding.accountAvatarImageView.setOnClickListener { avatarView -> - val intent = - ViewMediaActivity.newSingleImageIntent(avatarView.context, account.avatar) - - avatarView.transitionName = account.avatar - val options = ActivityOptionsCompat.makeSceneTransitionAnimation(this, avatarView, account.avatar) - - startActivity(intent, options.toBundle()) + binding.accountAvatarImageView.setOnClickListener { view -> + viewImage(view, account.avatar) + } + binding.accountHeaderImageView.setOnClickListener { view -> + viewImage(view, account.header) } } } + private fun viewImage(view: View, uri: String) { + view.transitionName = uri + startActivity( + ViewMediaActivity.newSingleImageIntent(view.context, uri), + ActivityOptionsCompat.makeSceneTransitionAnimation(this, view, uri).toBundle() + ) + } + /** * Update toolbar views for loaded account */ @@ -619,10 +655,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI binding.accountSubscribeButton.setOnClickListener { viewModel.changeSubscribingState() } - if (relation.notifying != null) + if (relation.notifying != null) { subscribing = relation.notifying - else if (relation.subscribing != null) + } else if (relation.subscribing != null) { subscribing = relation.subscribing + } } // remove the listener so it doesn't fire on non-user changes @@ -631,15 +668,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI binding.accountNoteTextInputLayout.visible(relation.note != null) binding.accountNoteTextInputLayout.editText?.setText(relation.note) - binding.accountNoteTextInputLayout.editText?.addTextChangedListener(noteWatcher) - - updateButtons() - } - - private val noteWatcher = object : DefaultTextWatcher() { - override fun afterTextChanged(s: Editable) { + noteWatcher = binding.accountNoteTextInputLayout.editText?.doAfterTextChanged { s -> viewModel.noteChanged(s.toString()) } + + updateButtons() } private fun updateFollowButton() { @@ -704,7 +737,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI } } - override fun onCreateOptionsMenu(menu: Menu): Boolean { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.account_toolbar, menu) val openAsItem = menu.findItem(R.id.action_open_as) @@ -716,7 +749,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI } if (!viewModel.isSelf) { - val block = menu.findItem(R.id.action_block) block.title = if (blocking) { getString(R.string.action_unblock) @@ -769,7 +801,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI menu.removeItem(R.id.action_add_or_remove_from_list) } - return super.onCreateOptionsMenu(menu) + menu.findItem(R.id.action_search)?.apply { + icon = IconicsDrawable(this@AccountActivity, GoogleMaterial.Icon.gmd_search).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.collapsingToolbar, android.R.attr.textColorPrimary) + } + } } private fun showFollowRequestPendingDialog() { @@ -857,7 +894,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI viewUrl(url, text = text) } - override fun onOptionsItemSelected(item: MenuItem): Boolean { + override fun onMenuItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_open_in_web -> { // If the account isn't loaded yet, eat the input. @@ -869,7 +906,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI R.id.action_open_as -> { loadedAccount?.let { loadedAccount -> showAccountChooserDialog( - item.title, false, + item.title, + false, object : AccountSelectionListener { override fun onAccountSelected(account: AccountEntity) { openAsAccount(loadedAccount.url, account) @@ -922,6 +960,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI viewModel.changeShowReblogsState() return true } + R.id.action_refresh -> { + binding.swipeToRefreshLayout.isRefreshing = true + onRefresh() + return true + } R.id.action_report -> { loadedAccount?.let { loadedAccount -> startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount.username)) @@ -929,23 +972,25 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI return true } } - return super.onOptionsItemSelected(item) + return false } override fun getActionButton(): FloatingActionButton? { return if (!blocking) { binding.accountFloatingActionButton - } else null + } else { + null + } } private fun getFullUsername(account: Account): String { - if (account.isRemote()) { - return "@" + account.username + return if (account.isRemote()) { + "@" + account.username } else { val localUsername = account.localUsername // Note: !! here will crash if this pane is ever shown to a logged-out user. With AccountActivity this is believed to be impossible. val domain = accountManager.activeAccount!!.domain - return "@$localUsername@$domain" + "@$localUsername@$domain" } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt index 1b4aa7f02..5b32e3404 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt @@ -2,7 +2,9 @@ package com.keylesspalace.tusky.components.account import android.util.Log import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.DomainMuteEvent import com.keylesspalace.tusky.appstore.EventHub @@ -16,22 +18,17 @@ import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource -import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.Success -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.disposables.Disposable +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response -import java.util.concurrent.TimeUnit import javax.inject.Inject class AccountViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub, private val accountManager: AccountManager -) : RxAwareViewModel() { +) : ViewModel() { val accountData = MutableLiveData>() val relationshipData = MutableLiveData>() @@ -44,15 +41,16 @@ class AccountViewModel @Inject constructor( lateinit var accountId: String var isSelf = false - private var noteDisposable: Disposable? = null + private var noteUpdateJob: Job? = null init { - eventHub.events - .subscribe { event -> + viewModelScope.launch { + eventHub.events.collect { event -> if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) { accountData.postValue(Success(event.newProfileData)) } - }.autoDispose() + } + } } private fun obtainAccount(reload: Boolean = false) { @@ -60,40 +58,41 @@ class AccountViewModel @Inject constructor( isDataLoading = true accountData.postValue(Loading()) - mastodonApi.account(accountId) - .subscribe( - { account -> - accountData.postValue(Success(account)) - isDataLoading = false - isRefreshing.postValue(false) - }, - { t -> - Log.w(TAG, "failed obtaining account", t) - accountData.postValue(Error()) - isDataLoading = false - isRefreshing.postValue(false) - } - ) - .autoDispose() + viewModelScope.launch { + mastodonApi.account(accountId) + .fold( + { account -> + accountData.postValue(Success(account)) + isDataLoading = false + isRefreshing.postValue(false) + }, + { t -> + Log.w(TAG, "failed obtaining account", t) + accountData.postValue(Error(cause = t)) + isDataLoading = false + isRefreshing.postValue(false) + } + ) + } } } private fun obtainRelationship(reload: Boolean = false) { if (relationshipData.value == null || reload) { - relationshipData.postValue(Loading()) - mastodonApi.relationships(listOf(accountId)) - .subscribe( - { relationships -> - relationshipData.postValue(Success(relationships[0])) - }, - { t -> - Log.w(TAG, "failed obtaining relationships", t) - relationshipData.postValue(Error()) - } - ) - .autoDispose() + viewModelScope.launch { + mastodonApi.relationships(listOf(accountId)) + .fold( + { relationships -> + relationshipData.postValue(if (relationships.isNotEmpty()) Success(relationships[0]) else Error()) + }, + { t -> + Log.w(TAG, "failed obtaining relationships", t) + relationshipData.postValue(Error(cause = t)) + } + ) + } } } @@ -134,42 +133,30 @@ class AccountViewModel @Inject constructor( } fun blockDomain(instance: String) { - mastodonApi.blockDomain(instance).enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - eventHub.dispatch(DomainMuteEvent(instance)) - val relation = relationshipData.value?.data - if (relation != null) { - relationshipData.postValue(Success(relation.copy(blockingDomain = true))) - } - } else { - Log.e(TAG, "Error muting %s".format(instance)) + viewModelScope.launch { + mastodonApi.blockDomain(instance).fold({ + eventHub.dispatch(DomainMuteEvent(instance)) + val relation = relationshipData.value?.data + if (relation != null) { + relationshipData.postValue(Success(relation.copy(blockingDomain = true))) } - } - - override fun onFailure(call: Call, t: Throwable) { - Log.e(TAG, "Error muting %s".format(instance), t) - } - }) + }, { e -> + Log.e(TAG, "Error muting $instance", e) + }) + } } fun unblockDomain(instance: String) { - mastodonApi.unblockDomain(instance).enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - val relation = relationshipData.value?.data - if (relation != null) { - relationshipData.postValue(Success(relation.copy(blockingDomain = false))) - } - } else { - Log.e(TAG, "Error unmuting %s".format(instance)) + viewModelScope.launch { + mastodonApi.unblockDomain(instance).fold({ + val relation = relationshipData.value?.data + if (relation != null) { + relationshipData.postValue(Success(relation.copy(blockingDomain = false))) } - } - - override fun onFailure(call: Call, t: Throwable) { - Log.e(TAG, "Error unmuting %s".format(instance), t) - } - }) + }, { e -> + Log.e(TAG, "Error unmuting $instance", e) + }) + } } fun changeShowReblogsState() { @@ -209,84 +196,88 @@ class AccountViewModel @Inject constructor( RelationShipAction.MUTE -> relation.copy(muting = true) RelationShipAction.UNMUTE -> relation.copy(muting = false) RelationShipAction.SUBSCRIBE -> { - if (isMastodon) + if (isMastodon) { relation.copy(notifying = true) - else relation.copy(subscribing = true) + } else { + relation.copy(subscribing = true) + } } RelationShipAction.UNSUBSCRIBE -> { - if (isMastodon) + if (isMastodon) { relation.copy(notifying = false) - else relation.copy(subscribing = false) + } else { + relation.copy(subscribing = false) + } } } relationshipData.postValue(Loading(newRelation)) } - try { - val relationship = when (relationshipAction) { - RelationShipAction.FOLLOW -> mastodonApi.followAccount( - accountId, - showReblogs = parameter ?: true - ) - RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId) - RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId) - RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId) - RelationShipAction.MUTE -> mastodonApi.muteAccount( - accountId, - parameter ?: true, - duration - ) - RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId) - RelationShipAction.SUBSCRIBE -> { - if (isMastodon) - mastodonApi.followAccount(accountId, notify = true) - else mastodonApi.subscribeAccount(accountId) - } - RelationShipAction.UNSUBSCRIBE -> { - if (isMastodon) - mastodonApi.followAccount(accountId, notify = false) - else mastodonApi.unsubscribeAccount(accountId) + val relationshipCall = when (relationshipAction) { + RelationShipAction.FOLLOW -> mastodonApi.followAccount( + accountId, + showReblogs = parameter ?: true + ) + RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId) + RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId) + RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId) + RelationShipAction.MUTE -> mastodonApi.muteAccount( + accountId, + parameter ?: true, + duration + ) + RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId) + RelationShipAction.SUBSCRIBE -> { + if (isMastodon) { + mastodonApi.followAccount(accountId, notify = true) + } else { + mastodonApi.subscribeAccount(accountId) } } - - relationshipData.postValue(Success(relationship)) - - when (relationshipAction) { - RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId)) - RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId)) - RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId)) - else -> { + RelationShipAction.UNSUBSCRIBE -> { + if (isMastodon) { + mastodonApi.followAccount(accountId, notify = false) + } else { + mastodonApi.unsubscribeAccount(accountId) } } - } catch (_: Throwable) { - relationshipData.postValue(Error(relation)) } + + relationshipCall.fold( + { relationship -> + relationshipData.postValue(Success(relationship)) + + when (relationshipAction) { + RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId)) + RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId)) + RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId)) + else -> { } + } + }, + { t -> + Log.w(TAG, "failed loading relationship", t) + relationshipData.postValue(Error(relation, cause = t)) + } + ) } fun noteChanged(newNote: String) { noteSaved.postValue(false) - noteDisposable?.dispose() - noteDisposable = Single.timer(1500, TimeUnit.MILLISECONDS) - .flatMap { - mastodonApi.updateAccountNote(accountId, newNote) - } - .doOnSuccess { - noteSaved.postValue(true) - } - .delay(4, TimeUnit.SECONDS) - .subscribe( - { - noteSaved.postValue(false) - }, - { - Log.e(TAG, "Error updating note", it) - } - ) - } - - override fun onCleared() { - super.onCleared() - noteDisposable?.dispose() + noteUpdateJob?.cancel() + noteUpdateJob = viewModelScope.launch { + delay(1500) + mastodonApi.updateAccountNote(accountId, newNote) + .fold( + { + noteSaved.postValue(true) + delay(4000) + noteSaved.postValue(false) + }, + { t -> + Log.w(TAG, "Error updating note", t) + } + ) + } } fun refresh() { @@ -294,12 +285,14 @@ class AccountViewModel @Inject constructor( } private fun reload(isReload: Boolean = false) { - if (isDataLoading) + if (isDataLoading) { return + } accountId.let { obtainAccount(isReload) - if (!isSelf) + if (!isSelf) { obtainRelationship(isReload) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt index 874d4b9f2..d527f613d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt @@ -65,7 +65,7 @@ class ListsForAccountFragment : DialogFragment(), Injectable { dialog?.apply { window?.setLayout( LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT ) } } @@ -172,7 +172,7 @@ class ListsForAccountFragment : DialogFragment(), Injectable { ListAdapter>(Differ) { override fun onCreateViewHolder( parent: ViewGroup, - viewType: Int, + viewType: Int ): BindingHolder { val binding = ItemAddOrRemoveFromListBinding.inflate(LayoutInflater.from(parent.context), parent, false) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt index b571390e5..110096966 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt @@ -35,23 +35,23 @@ import javax.inject.Inject data class AccountListState( val list: MastoList, - val includesAccount: Boolean, + val includesAccount: Boolean ) data class ActionError( val error: Throwable, val type: Type, - val listId: String, + val listId: String ) : Throwable(error) { enum class Type { ADD, - REMOVE, + REMOVE } } @OptIn(ExperimentalCoroutinesApi::class) class ListsForAccountViewModel @Inject constructor( - private val mastodonApi: MastodonApi, + private val mastodonApi: MastodonApi ) : ViewModel() { private lateinit var accountId: String @@ -75,14 +75,14 @@ class ListsForAccountViewModel @Inject constructor( runCatching { val (all, includes) = listOf( async { mastodonApi.getLists() }, - async { mastodonApi.getListsIncludesAccount(accountId) }, + async { mastodonApi.getListsIncludesAccount(accountId) } ).awaitAll() _states.emit( all.getOrThrow().map { list -> AccountListState( list = list, - includesAccount = includes.getOrThrow().any { it.id == list.id }, + includesAccount = includes.getOrThrow().any { it.id == list.id } ) } ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt index 457cda7b9..df87372eb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt @@ -16,15 +16,21 @@ package com.keylesspalace.tusky.components.account.media import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import androidx.core.app.ActivityOptionsCompat +import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.databinding.FragmentTimelineBinding @@ -39,20 +45,22 @@ import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData +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.flow.collectLatest import kotlinx.coroutines.launch import java.io.IOException import javax.inject.Inject /** - * Created by charlag on 26/10/2017. - * * Fragment with multiple columns of media previews for the specified account. */ - class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFragment, + MenuProvider, Injectable { @Inject @@ -73,6 +81,7 @@ class AccountMediaFragment : } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) val alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia @@ -95,6 +104,8 @@ class AccountMediaFragment : binding.recyclerView.adapter = adapter binding.swipeRefreshLayout.isEnabled = false + binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() } + binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) binding.statusView.visibility = View.GONE @@ -108,6 +119,10 @@ class AccountMediaFragment : binding.statusView.hide() binding.progressBar.hide() + if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) { + binding.swipeRefreshLayout.isRefreshing = false + } + if (adapter.itemCount == 0) { when (loadState.refresh) { is LoadState.NotLoading -> { @@ -133,6 +148,27 @@ class AccountMediaFragment : } } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.fragment_account_media, menu) + menu.findItem(R.id.action_refresh)?.apply { + icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_refresh -> { + binding.swipeRefreshLayout.isRefreshing = true + refreshContent() + true + } + else -> false + } + } + private fun onAttachmentClick(selected: AttachmentViewData, view: View) { if (!selected.isRevealed) { viewModel.revealAttachment(selected) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt index fcc3bcf9d..48b3a21da 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt @@ -40,7 +40,7 @@ class AccountMediaGridAdapter( } ) { - private val baseItemBackgroundColor = MaterialColors.getColor(context, R.attr.colorSurface, Color.BLACK) + private val baseItemBackgroundColor = MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, Color.BLACK) private val videoIndicator = AppCompatResources.getDrawable(context, R.drawable.ic_play_indicator) private val mediaHiddenDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_hide_media_24dp) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaPagingSource.kt index 60c767436..0ed67cf45 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaPagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaPagingSource.kt @@ -26,7 +26,6 @@ class AccountMediaPagingSource( override fun getRefreshKey(state: PagingState): String? = null override suspend fun load(params: LoadParams): LoadResult { - return if (params is LoadParams.Refresh) { val list = viewModel.attachmentData.toList() LoadResult.Page(list, null, list.lastOrNull()?.statusId) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt index 81865b0fe..315b0380c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt @@ -34,7 +34,6 @@ class AccountMediaRemoteMediator( loadType: LoadType, state: PagingState ): MediatorResult { - try { val statusResponse = when (loadType) { LoadType.REFRESH -> { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt index 5c3528e9d..ddbcb71d0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt @@ -25,7 +25,7 @@ import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.viewdata.AttachmentViewData import javax.inject.Inject -class AccountMediaViewModel @Inject constructor ( +class AccountMediaViewModel @Inject constructor( api: MastodonApi ) : ViewModel() { diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt similarity index 88% rename from app/src/main/java/com/keylesspalace/tusky/AccountListActivity.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt index ca23f7912..f5981c0bc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt @@ -13,18 +13,20 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky +package com.keylesspalace.tusky.components.accountlist 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.R import com.keylesspalace.tusky.databinding.ActivityAccountListBinding -import com.keylesspalace.tusky.fragment.AccountListFragment 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 @@ -63,10 +65,9 @@ class AccountListActivity : BaseActivity(), HasAndroidInjector { setDisplayShowHomeEnabled(true) } - supportFragmentManager - .beginTransaction() - .replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked)) - .commit() + supportFragmentManager.commit { + replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked)) + } } override fun androidInjector() = dispatchingAndroidInjector @@ -76,8 +77,6 @@ class AccountListActivity : BaseActivity(), HasAndroidInjector { private const val EXTRA_ID = "id" private const val EXTRA_ACCOUNT_LOCKED = "acc_locked" - @JvmStatic - @JvmOverloads fun newIntent(context: Context, type: Type, id: String? = null, accountLocked: Boolean = false): Intent { return Intent(context, AccountListActivity::class.java).apply { putExtra(EXTRA_TYPE, type) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt similarity index 86% rename from app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt index 9fa321d30..943af596e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.fragment +package com.keylesspalace.tusky.components.accountlist import android.os.Bundle import android.util.Log @@ -27,25 +27,30 @@ import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator +import at.connyduck.calladapter.networkresult.fold import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from import autodispose2.autoDispose import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.AccountListActivity.Type import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.BottomSheetActivity +import com.keylesspalace.tusky.PostLookupFallbackBehavior import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.AccountAdapter -import com.keylesspalace.tusky.adapter.BlocksAdapter -import com.keylesspalace.tusky.adapter.FollowAdapter -import com.keylesspalace.tusky.adapter.FollowRequestsAdapter -import com.keylesspalace.tusky.adapter.FollowRequestsHeaderAdapter -import com.keylesspalace.tusky.adapter.MutesAdapter +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 +import com.keylesspalace.tusky.components.accountlist.adapter.BlocksAdapter +import com.keylesspalace.tusky.components.accountlist.adapter.FollowAdapter +import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsAdapter +import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsHeaderAdapter +import com.keylesspalace.tusky.components.accountlist.adapter.MutesAdapter import com.keylesspalace.tusky.databinding.FragmentAccountListBinding import com.keylesspalace.tusky.db.AccountManager 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 @@ -59,10 +64,15 @@ 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 + @Inject lateinit var accountManager: AccountManager @@ -83,15 +93,15 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.recyclerView.setHasFixedSize(true) val layoutManager = LinearLayoutManager(view.context) binding.recyclerView.layoutManager = layoutManager (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false - binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) + binding.swipeRefreshLayout.setOnRefreshListener { fetchAccounts() } + binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + val pm = PreferenceManager.getDefaultSharedPreferences(view.context) val animateAvatar = pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) @@ -101,8 +111,11 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay) Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay) Type.FOLLOW_REQUESTS -> { - val headerAdapter = FollowRequestsHeaderAdapter(accountManager.activeAccount!!.domain, arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true) - val followRequestsAdapter = FollowRequestsAdapter(this, animateAvatar, animateEmojis, showBotOverlay) + val headerAdapter = FollowRequestsHeaderAdapter( + instanceName = accountManager.activeAccount!!.domain, + accountLocked = arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true + ) + val followRequestsAdapter = FollowRequestsAdapter(this, this, animateAvatar, animateEmojis, showBotOverlay) binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter) followRequestsAdapter } @@ -126,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) @@ -133,6 +151,10 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct } } + override fun onViewUrl(url: String, text: String) { + (activity as BottomSheetActivity?)?.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER, text) + } + override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { viewLifecycleOwner.lifecycleScope.launch { try { @@ -225,7 +247,6 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct accountId: String, position: Int ) { - if (accept) { api.authorizeFollowRequest(accountId) } else { @@ -285,6 +306,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct return } fetching = true + binding.swipeRefreshLayout.isRefreshing = true if (fromId != null) { binding.recyclerView.post { adapter.setBottomLoading(true) } @@ -293,6 +315,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct viewLifecycleOwner.lifecycleScope.launch { try { val response = getFetchCallByListType(fromId) + if (!response.isSuccessful) { onFetchAccountsFailure(Exception(response.message())) return@launch @@ -315,6 +338,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct private fun onFetchAccountsSuccess(accounts: List, linkHeader: String?) { adapter.setBottomLoading(false) + binding.swipeRefreshLayout.isRefreshing = false val links = HttpHeaderLink.parse(linkHeader) val next = HttpHeaderLink.findByRelationType(links, "next") @@ -347,12 +371,12 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct } private fun fetchRelationships(ids: List) { - api.relationships(ids) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe(::onFetchRelationshipsSuccess) { - onFetchRelationshipsFailure(ids) - } + lifecycleScope.launch { + api.relationships(ids) + .fold(::onFetchRelationshipsSuccess) { throwable -> + Log.e(TAG, "Fetch failure for relationships of accounts: $ids", throwable) + } + } } private fun onFetchRelationshipsSuccess(relationships: List) { @@ -362,12 +386,9 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct mutesAdapter.updateMutingNotificationsMap(mutingNotificationsMap) } - private fun onFetchRelationshipsFailure(ids: List) { - Log.e(TAG, "Fetch failure for relationships of accounts: $ids") - } - private fun onFetchAccountsFailure(throwable: Throwable) { fetching = false + binding.swipeRefreshLayout.isRefreshing = false Log.e(TAG, "Fetch failure", throwable) if (adapter.itemCount == 0) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt similarity index 89% rename from app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt index bbd83df32..7d050e7e4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt @@ -12,24 +12,26 @@ * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.adapter +package com.keylesspalace.tusky.components.accountlist.adapter import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView -import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemFooterBinding import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.removeDuplicates /** Generic adapter with bottom loading indicator. */ abstract class AccountAdapter internal constructor( - var accountActionListener: AccountActionListener, + protected val accountActionListener: AccountActionListener, protected val animateAvatar: Boolean, protected val animateEmojis: Boolean, protected val showBotOverlay: Boolean ) : RecyclerView.Adapter() { - var accountList = mutableListOf() + + protected var accountList: MutableList = mutableListOf() private var bottomLoading: Boolean = false override fun getItemCount(): Int { @@ -59,11 +61,10 @@ abstract class AccountAdapter internal constructo } private fun createFooterViewHolder( - parent: ViewGroup, + parent: ViewGroup ): RecyclerView.ViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_footer, parent, false) - return LoadingFooterViewHolder(view) + val binding = ItemFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return BindingHolder(binding) } override fun getItemViewType(position: Int): Int { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt new file mode 100644 index 000000000..2ef520d5e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt @@ -0,0 +1,68 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.accountlist.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemBlockedUserBinding +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.visible + +/** Displays a list of blocked accounts. */ +class BlocksAdapter( + accountActionListener: AccountActionListener, + animateAvatar: Boolean, + animateEmojis: Boolean, + showBotOverlay: Boolean +) : AccountAdapter>( + accountActionListener = accountActionListener, + animateAvatar = animateAvatar, + animateEmojis = animateEmojis, + showBotOverlay = showBotOverlay +) { + + override fun createAccountViewHolder(parent: ViewGroup): BindingHolder { + val binding = ItemBlockedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return BindingHolder(binding) + } + + override fun onBindAccountViewHolder(viewHolder: BindingHolder, position: Int) { + val account = accountList[position] + val binding = viewHolder.binding + val context = binding.root.context + + val emojifiedName = account.name.emojify(account.emojis, binding.blockedUserDisplayName, animateEmojis) + binding.blockedUserDisplayName.text = emojifiedName + val formattedUsername = context.getString(R.string.post_username_format, account.username) + binding.blockedUserUsername.text = formattedUsername + + val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) + loadAvatar(account.avatar, binding.blockedUserAvatar, avatarRadius, animateAvatar) + + binding.blockedUserBotBadge.visible(showBotOverlay && account.bot) + + binding.blockedUserUnblock.setOnClickListener { + accountActionListener.onBlock(false, account.id, position) + } + binding.root.setOnClickListener { + accountActionListener.onViewAccount(account.id) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt similarity index 80% rename from app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt index 5c5463056..87b62486d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt @@ -12,10 +12,12 @@ * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.adapter + +package com.keylesspalace.tusky.components.accountlist.adapter import android.view.LayoutInflater import android.view.ViewGroup +import com.keylesspalace.tusky.adapter.AccountViewHolder import com.keylesspalace.tusky.databinding.ItemAccountBinding import com.keylesspalace.tusky.interfaces.AccountActionListener @@ -26,17 +28,14 @@ class FollowAdapter( animateEmojis: Boolean, showBotOverlay: Boolean ) : AccountAdapter( - accountActionListener, - animateAvatar, - animateEmojis, - showBotOverlay + accountActionListener = accountActionListener, + animateAvatar = animateAvatar, + animateEmojis = animateEmojis, + showBotOverlay = showBotOverlay ) { + override fun createAccountViewHolder(parent: ViewGroup): AccountViewHolder { - val binding = ItemAccountBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) + val binding = ItemAccountBinding.inflate(LayoutInflater.from(parent.context), parent, false) return AccountViewHolder(binding) } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt similarity index 62% rename from app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt index 95d944bda..fc860e59e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt @@ -12,29 +12,51 @@ * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.adapter + +package com.keylesspalace.tusky.components.accountlist.adapter import android.view.LayoutInflater 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 -) : AccountAdapter(accountActionListener, animateAvatar, animateEmojis, showBotOverlay) { +) : AccountAdapter( + accountActionListener = accountActionListener, + animateAvatar = animateAvatar, + animateEmojis = animateEmojis, + showBotOverlay = showBotOverlay +) { + override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder { val binding = ItemFollowRequestBinding.inflate( - LayoutInflater.from(parent.context), parent, false + LayoutInflater.from(parent.context), + parent, + false + ) + return FollowRequestViewHolder( + binding, + accountActionListener, + linkListener, + showHeader = false ) - return FollowRequestViewHolder(binding, false) } override fun onBindAccountViewHolder(viewHolder: FollowRequestViewHolder, position: Int) { - viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis, showBotOverlay) + viewHolder.setupWithAccount( + account = accountList[position], + animateAvatar = animateAvatar, + animateEmojis = animateEmojis, + showBotOverlay = showBotOverlay + ) viewHolder.setupActionListener(accountActionListener, accountList[position].id) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsHeaderAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsHeaderAdapter.kt similarity index 54% rename from app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsHeaderAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsHeaderAdapter.kt index 2480086e4..85cf4e20a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsHeaderAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsHeaderAdapter.kt @@ -13,27 +13,28 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.adapter +package com.keylesspalace.tusky.components.accountlist.adapter import android.view.LayoutInflater import android.view.ViewGroup -import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemFollowRequestsHeaderBinding +import com.keylesspalace.tusky.util.BindingHolder -class FollowRequestsHeaderAdapter(private val instanceName: String, private val accountLocked: Boolean) : RecyclerView.Adapter() { +class FollowRequestsHeaderAdapter( + private val instanceName: String, + private val accountLocked: Boolean +) : RecyclerView.Adapter>() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_follow_requests_header, parent, false) as TextView - return HeaderViewHolder(view) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { + val binding = ItemFollowRequestsHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return BindingHolder(binding) } - override fun onBindViewHolder(viewHolder: HeaderViewHolder, position: Int) { - viewHolder.textView.text = viewHolder.textView.context.getString(R.string.follow_requests_info, instanceName) + override fun onBindViewHolder(viewHolder: BindingHolder, position: Int) { + viewHolder.binding.root.text = viewHolder.binding.root.context.getString(R.string.follow_requests_info, instanceName) } override fun getItemCount() = if (accountLocked) 0 else 1 } - -class HeaderViewHolder(var textView: TextView) : RecyclerView.ViewHolder(textView) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt similarity index 74% rename from app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt index 42e19c659..288d13394 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt @@ -1,4 +1,19 @@ -package com.keylesspalace.tusky.adapter +/* 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 . */ + +package com.keylesspalace.tusky.components.accountlist.adapter import android.view.LayoutInflater import android.view.ViewGroup @@ -9,22 +24,21 @@ import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.visible -/** - * Displays a list of muted accounts with mute/unmute account and mute/unmute notifications - * buttons. - * */ +/** Displays a list of muted accounts with mute/unmute account button and mute/unmute notifications switch */ class MutesAdapter( accountActionListener: AccountActionListener, animateAvatar: Boolean, animateEmojis: Boolean, showBotOverlay: Boolean ) : AccountAdapter>( - accountActionListener, - animateAvatar, - animateEmojis, - showBotOverlay + accountActionListener = accountActionListener, + animateAvatar = animateAvatar, + animateEmojis = animateEmojis, + showBotOverlay = showBotOverlay ) { + private val mutingNotificationsMap = HashMap() override fun createAccountViewHolder(parent: ViewGroup): BindingHolder { @@ -48,6 +62,8 @@ class MutesAdapter( val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) loadAvatar(account.avatar, binding.mutedUserAvatar, avatarRadius, animateAvatar) + binding.mutedUserBotBadge.visible(showBotOverlay && account.bot) + val unmuteString = context.getString(R.string.action_unmute_desc, formattedUsername) binding.mutedUserUnmute.contentDescription = unmuteString ViewCompat.setTooltipText(binding.mutedUserUnmute, unmuteString) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt index 6ebe76b7a..8f30c5e49 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt @@ -80,7 +80,7 @@ class AnnouncementAdapter( item.reactions.forEachIndexed { i, reaction -> ( chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip? - ?: Chip(ContextThemeWrapper(chips.context, R.style.Widget_MaterialComponents_Chip_Choice)).apply { + ?: Chip(ContextThemeWrapper(chips.context, com.google.android.material.R.style.Widget_MaterialComponents_Chip_Choice)).apply { isCheckable = true checkedIcon = null chips.addView(this, i) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt index f9aa5af48..733b2fec8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt @@ -19,12 +19,17 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.widget.PopupWindow import androidx.activity.viewModels +import androidx.core.view.MenuProvider import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity @@ -39,11 +44,21 @@ import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.view.EmojiPicker +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 javax.inject.Inject -class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, OnEmojiSelectedListener, Injectable { +class AnnouncementsActivity : + BottomSheetActivity(), + AnnouncementActionListener, + OnEmojiSelectedListener, + MenuProvider, + Injectable { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -54,8 +69,8 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, private lateinit var adapter: AnnouncementAdapter - private val picker by lazy { EmojiPicker(this) } - private val pickerDialog by lazy { + private val picker by unsafeLazy { EmojiPicker(this) } + private val pickerDialog by unsafeLazy { PopupWindow(this) .apply { contentView = picker @@ -70,6 +85,7 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) + addMenuProvider(this) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.apply { @@ -129,6 +145,27 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, binding.progressBar.show() } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.activity_announcements, menu) + menu.findItem(R.id.action_search)?.apply { + icon = IconicsDrawable(this@AnnouncementsActivity, GoogleMaterial.Icon.gmd_search).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.includedToolbar.toolbar, android.R.attr.textColorPrimary) + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_refresh -> { + binding.swipeRefreshLayout.isRefreshing = true + refreshAnnouncements() + true + } + else -> false + } + } + private fun refreshAnnouncements() { viewModel.load() binding.swipeRefreshLayout.isRefreshing = true diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt index c7e6781a1..8abad91ac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt @@ -107,8 +107,7 @@ class AnnouncementsViewModel @Inject constructor( } else { listOf( *announcement.reactions.toTypedArray(), - emojis.value!!.find { emoji -> emoji.shortcode == name } - !!.run { + emojis.value!!.find { emoji -> emoji.shortcode == name }!!.run { Announcement.Reaction( name, 1, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt index 0da5dac1b..0f42a216e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -32,6 +32,8 @@ import android.os.Build import android.os.Bundle import android.os.Parcelable import android.provider.MediaStore +import android.text.Spanned +import android.text.style.URLSpan import android.util.Log import android.view.KeyEvent import android.view.MenuItem @@ -52,11 +54,13 @@ import androidx.appcompat.app.AlertDialog import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import androidx.core.content.res.use import androidx.core.view.ContentInfoCompat import androidx.core.view.OnReceiveContentListener import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged +import androidx.core.widget.doOnTextChanged import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager @@ -92,18 +96,18 @@ import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.APP_THEME_DEFAULT +import com.keylesspalace.tusky.util.MentionSpan import com.keylesspalace.tusky.util.PickMediaFiles -import com.keylesspalace.tusky.util.afterTextChanged -import com.keylesspalace.tusky.util.getInitialLanguage +import com.keylesspalace.tusky.util.getInitialLanguages import com.keylesspalace.tusky.util.getLocaleList import com.keylesspalace.tusky.util.getMediaSize import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.highlightSpans import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.modernLanguageCode -import com.keylesspalace.tusky.util.onTextChanged import com.keylesspalace.tusky.util.setDrawableTint import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import com.mikepenz.iconics.IconicsDrawable @@ -145,7 +149,7 @@ class ComposeActivity : private var photoUploadUri: Uri? = null - private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) } + private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) } @VisibleForTesting var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT @@ -228,13 +232,13 @@ class ComposeActivity : val mediaAdapter = MediaPreviewAdapter( this, onAddCaption = { item -> - CaptionDialog.newInstance(item.localId, item.description, item.uri) - .show(supportFragmentManager, "caption_dialog") + CaptionDialog.newInstance(item.localId, item.description, item.uri).show(supportFragmentManager, "caption_dialog") }, onAddFocus = { item -> makeFocusDialog(item.focus, item.uri) { newFocus -> viewModel.updateFocus(item.localId, newFocus) } + // TODO this is inconsistent to CaptionDialog (device rotation)? }, onEditImage = this::editImageInQueue, onRemove = this::removeMediaFromQueue @@ -273,7 +277,7 @@ class ComposeActivity : binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt) } - setupLanguageSpinner(getInitialLanguage(composeOptions?.language, accountManager.activeAccount)) + setupLanguageSpinner(getInitialLanguages(composeOptions?.language, accountManager.activeAccount)) setupComposeField(preferences, viewModel.startingText) setupDefaultTagViews(preferences) setupContentWarningField(composeOptions?.contentWarning) @@ -420,7 +424,7 @@ class ComposeActivity : if (startingContentWarning != null) { binding.composeContentWarningField.setText(startingContentWarning) } - binding.composeContentWarningField.onTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() } + binding.composeContentWarningField.doOnTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() } } private fun setupComposeField(preferences: SharedPreferences, startingText: String?) { @@ -443,8 +447,8 @@ class ComposeActivity : val mentionColour = binding.composeEditField.linkTextColors.defaultColor highlightSpans(binding.composeEditField.text, mentionColour) - binding.composeEditField.afterTextChanged { editable -> - highlightSpans(editable, mentionColour) + binding.composeEditField.doAfterTextChanged { editable -> + highlightSpans(editable!!, mentionColour) updateVisibleCharactersLeft() } @@ -535,7 +539,9 @@ class ComposeActivity : preferences.edit() .putBoolean(PREF_USE_DEFAULT_TAG, isChecked) .apply() - eventHub.dispatch(PreferenceChangedEvent(PREF_USE_DEFAULT_TAG)) + lifecycleScope.launch { + eventHub.dispatch(PreferenceChangedEvent(PREF_USE_DEFAULT_TAG)) + } } binding.editTextDefaultText.setText(preferences.getString(PREF_DEFAULT_TAG, "")) @@ -543,7 +549,9 @@ class ComposeActivity : preferences.edit() .putString(PREF_DEFAULT_TAG, it.toString()) .apply() - eventHub.dispatch(PreferenceChangedEvent(PREF_DEFAULT_TAG)) + lifecycleScope.launch { + eventHub.dispatch(PreferenceChangedEvent(PREF_DEFAULT_TAG)) + } } } @@ -613,7 +621,7 @@ class ComposeActivity : ) } - private fun setupLanguageSpinner(initialLanguage: String) { + private fun setupLanguageSpinner(initialLanguages: List) { binding.composePostLanguageButton.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { viewModel.postLanguage = (parent.adapter.getItem(position) as Locale).modernLanguageCode @@ -624,7 +632,7 @@ class ComposeActivity : } } binding.composePostLanguageButton.apply { - adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, getLocaleList(initialLanguage)) + adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, getLocaleList(initialLanguages)) setSelection(0) } } @@ -640,10 +648,10 @@ class ComposeActivity : } private fun setupAvatar(activeAccount: AccountEntity) { - val actionBarSizeAttr = intArrayOf(R.attr.actionBarSize) - val a = obtainStyledAttributes(null, actionBarSizeAttr) - val avatarSize = a.getDimensionPixelSize(0, 1) - a.recycle() + val actionBarSizeAttr = intArrayOf(androidx.appcompat.R.attr.actionBarSize) + val avatarSize = obtainStyledAttributes(null, actionBarSizeAttr).use { a -> + a.getDimensionPixelSize(0, 1) + } val animateAvatars = preferences.getBoolean("animateGifAvatars", false) loadAvatar( @@ -768,7 +776,7 @@ class ComposeActivity : var oneMediaWithoutDescription = false for (media in viewModel.media.value) { - if (media.description == null || media.description.isEmpty()) { + if (media.description.isNullOrEmpty()) { oneMediaWithoutDescription = true break } @@ -879,25 +887,26 @@ class ComposeActivity : } private fun onMediaPick() { - addMediaBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { - override fun onStateChanged(bottomSheet: View, newState: Int) { - // Wait until bottom sheet is not collapsed and show next screen after - if (newState == BottomSheetBehavior.STATE_COLLAPSED) { - addMediaBehavior.removeBottomSheetCallback(this) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions( - this@ComposeActivity, - arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), - PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE - ) - } else { - pickMediaFile.launch(true) + addMediaBehavior.addBottomSheetCallback( + object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + // Wait until bottom sheet is not collapsed and show next screen after + if (newState == BottomSheetBehavior.STATE_COLLAPSED) { + addMediaBehavior.removeBottomSheetCallback(this) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions( + this@ComposeActivity, + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), + PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE + ) + } else { + pickMediaFile.launch(true) + } } } - } - override fun onSlide(bottomSheet: View, slideOffset: Float) {} - } + override fun onSlide(bottomSheet: View, slideOffset: Float) {} + } ) addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED } @@ -953,23 +962,13 @@ class ComposeActivity : @VisibleForTesting fun calculateTextLength(): Int { - var offset = 0 - val urlSpans = binding.composeEditField.urls - if (urlSpans != null) { - for (span in urlSpans) { - // it's expected that this will be negative - // when the url length is less than the reserved character count - offset += (span.url.length - charactersReservedPerUrl) - } - } - var length = binding.composeEditField.length() - offset - if (binding.checkboxUseDefaultText.isChecked) { - length += 1 + binding.editTextDefaultText.length() - } - if (viewModel.showContentWarning.value) { - length += binding.composeContentWarningField.length() - } - return length + return statusLength( + binding.composeEditField.text, + binding.composeContentWarningField.text, + binding.checkboxUseDefaultText.isChecked, + binding.editTextDefaultText.text, + charactersReservedPerUrl + ) } @VisibleForTesting @@ -1054,7 +1053,8 @@ class ComposeActivity : pickMediaFile.launch(true) } else { Snackbar.make( - binding.activityCompose, R.string.error_media_upload_permission, + binding.activityCompose, + R.string.error_media_upload_permission, Snackbar.LENGTH_SHORT ).apply { setAction(R.string.action_retry) { onMediaPick() } @@ -1088,9 +1088,13 @@ class ComposeActivity : private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) { button.isEnabled = clickable setDrawableTint( - this, button.drawable, - if (colorActive) android.R.attr.textColorTertiary - else R.attr.textColorDisabled + this, + button.drawable, + if (colorActive) { + android.R.attr.textColorTertiary + } else { + R.attr.textColorDisabled + } ) } @@ -1098,8 +1102,11 @@ class ComposeActivity : binding.addPollTextActionTextView.isEnabled = enable val textColor = MaterialColors.getColor( binding.addPollTextActionTextView, - if (enable) android.R.attr.textColorTertiary - else R.attr.textColorDisabled + if (enable) { + android.R.attr.textColorTertiary + } else { + R.attr.textColorDisabled + } ) binding.addPollTextActionTextView.setTextColor(textColor) binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN) @@ -1275,8 +1282,11 @@ class ComposeActivity : lifecycleScope.launch { val dialog = if (viewModel.shouldShowSaveDraftDialog()) { ProgressDialog.show( - this@ComposeActivity, null, - getString(R.string.saving_draft), true, false + this@ComposeActivity, + null, + getString(R.string.saving_draft), + true, + false ) } else { null @@ -1337,11 +1347,7 @@ class ComposeActivity : } override fun onUpdateDescription(localId: Int, description: String) { - lifecycleScope.launch { - if (!viewModel.updateDescription(localId, description)) { - Toast.makeText(this@ComposeActivity, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show() - } - } + viewModel.updateDescription(localId, description) } /** @@ -1438,5 +1444,57 @@ class ComposeActivity : fun canHandleMimeType(mimeType: String?): Boolean { return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType.startsWith("audio/") || mimeType == "text/plain") } + + /** + * Calculate the effective status length. + * + * Some text is counted differently: + * + * In the status body: + * + * - URLs always count for [urlLength] characters irrespective of their actual length + * (https://docs.joinmastodon.org/user/posting/#links) + * - Mentions ("@user@some.instance") only count the "@user" part + * (https://docs.joinmastodon.org/user/posting/#mentions) + * - Hashtags are always treated as their actual length, including the "#" + * (https://docs.joinmastodon.org/user/posting/#hashtags) + * + * Content warning text is always treated as its full length, URLs and other entities + * are not treated differently. + * + * @param body status body text + * @param contentWarning optional content warning text + * @param urlLength the number of characters attributed to URLs + * @return the effective status length + */ + @JvmStatic + fun statusLength(body: Spanned, contentWarning: Spanned?, useDefault: Boolean, default: Spanned, urlLength: Int): Int { + var length = body.length - body.getSpans(0, body.length, URLSpan::class.java) + .fold(0) { acc, span -> + // Accumulate a count of characters to be *ignored* in the final length + acc + when (span) { + is MentionSpan -> { + // Ignore everything from the second "@" (if present) + span.url.length - ( + span.url.indexOf("@", 1).takeIf { it >= 0 } + ?: span.url.length + ) + } + else -> { + // Expected to be negative if the URL length < maxUrlLength + span.url.length - urlLength + } + } + } + + // Content warning text is treated as is, URLs or mentions there are not special + contentWarning?.let { length += it.length } + + if (useDefault) { + length += 1 + default.length + } + + return length + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt index 5d3d829e2..5927ee8b0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -46,7 +46,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject @@ -141,7 +140,7 @@ class ComposeViewModel @Inject constructor( ): QueuedMedia { var stashMediaItem: QueuedMedia? = null - media.updateAndGet { mediaValue -> + media.update { mediaList -> val mediaItem = QueuedMedia( localId = mediaUploader.getNewLocalMediaId(), uri = uri, @@ -155,11 +154,11 @@ class ComposeViewModel @Inject constructor( if (replaceItem != null) { mediaUploader.cancelUploadScope(replaceItem.localId) - mediaValue.map { + mediaList.map { if (it.localId == replaceItem.localId) mediaItem else it } } else { // Append - mediaValue + mediaItem + mediaList + mediaItem } } val mediaItem = stashMediaItem!! // stashMediaItem is always non-null and uncaptured at this point, but Kotlin doesn't know that @@ -180,13 +179,13 @@ class ComposeViewModel @Inject constructor( state = if (event.processed) { QueuedMedia.State.PROCESSED } else { QueuedMedia.State.UNPROCESSED } ) is UploadEvent.ErrorEvent -> { - media.update { mediaValue -> mediaValue.filter { it.localId != mediaItem.localId } } + media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } } uploadError.emit(event.error) return@collect } } - media.update { mediaValue -> - mediaValue.map { mediaItem -> + media.update { mediaList -> + mediaList.map { mediaItem -> if (mediaItem.localId == newMediaItem.localId) { newMediaItem } else { @@ -200,7 +199,7 @@ class ComposeViewModel @Inject constructor( } private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) { - media.update { mediaValue -> + media.update { mediaList -> val mediaItem = QueuedMedia( localId = mediaUploader.getNewLocalMediaId(), uri = uri, @@ -212,13 +211,13 @@ class ComposeViewModel @Inject constructor( focus = focus, state = QueuedMedia.State.PUBLISHED ) - mediaValue + mediaItem + mediaList + mediaItem } } fun removeMediaFromQueue(item: QueuedMedia) { mediaUploader.cancelUploadScope(item.localId) - media.update { mediaValue -> mediaValue.filter { it.localId != item.localId } } + media.update { mediaList -> mediaList.filter { it.localId != item.localId } } } fun toggleMarkSensitive() { @@ -285,7 +284,7 @@ class ComposeViewModel @Inject constructor( failedToSendAlert = false, scheduledAt = scheduledAt.value, language = postLanguage, - statusId = originalStatusId, + statusId = originalStatusId ) } @@ -297,7 +296,6 @@ class ComposeViewModel @Inject constructor( content: String, spoilerText: String ) { - if (!scheduledTootId.isNullOrEmpty()) { api.deleteScheduledStatus(scheduledTootId!!) } @@ -335,10 +333,9 @@ class ComposeViewModel @Inject constructor( serviceClient.sendToot(tootToSend) } - // Updates a QueuedMedia item arbitrarily, then sends description and focus to server - private suspend fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia): Boolean { - val newMediaList = media.updateAndGet { mediaValue -> - mediaValue.map { mediaItem -> + private fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia) { + media.update { mediaList -> + mediaList.map { mediaItem -> if (mediaItem.localId == localId) { mutator(mediaItem) } else { @@ -346,30 +343,16 @@ class ComposeViewModel @Inject constructor( } } } - - val updatedItem = newMediaList.find { it.localId == localId } - if (updatedItem?.id != null) { - val focus = updatedItem.focus - val focusString = if (focus != null) "${focus.x},${focus.y}" else null - return api.updateMedia(updatedItem.id, updatedItem.description, focusString) - .fold({ - true - }, { throwable -> - Log.w(TAG, "failed to update media", throwable) - false - }) - } - return true } - suspend fun updateDescription(localId: Int, description: String): Boolean { - return updateMediaItem(localId) { mediaItem -> + fun updateDescription(localId: Int, description: String) { + updateMediaItem(localId) { mediaItem -> mediaItem.copy(description = description) } } - suspend fun updateFocus(localId: Int, focus: Attachment.Focus): Boolean { - return updateMediaItem(localId) { mediaItem -> + fun updateFocus(localId: Int, focus: Attachment.Focus) { + updateMediaItem(localId) { mediaItem -> mediaItem.copy(focus = focus) } } @@ -414,7 +397,6 @@ class ComposeViewModel @Inject constructor( } fun setup(composeOptions: ComposeActivity.ComposeOptions?, useCache: Boolean) { - if (setupComplete) { return } @@ -453,14 +435,16 @@ class ComposeViewModel @Inject constructor( pickMedia(attachment.uri, attachment.description, attachment.focus) } } - } else composeOptions?.mediaAttachments?.forEach { a -> - // when coming from redraft or ScheduledTootActivity - val mediaType = when (a.type) { - Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO - Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE - Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO + } else { + composeOptions?.mediaAttachments?.forEach { a -> + // when coming from redraft or ScheduledTootActivity + val mediaType = when (a.type) { + Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO + Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE + Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO + } + addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus) } - addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus) } draftId = composeOptions?.draftId ?: 0 diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt index 4976ae0c5..39b444688 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt @@ -41,7 +41,6 @@ fun downsizeImage( contentResolver: ContentResolver, tempFile: File ): Boolean { - val decodeBoundsInputStream = try { contentResolver.openInputStream(uri) } catch (e: FileNotFoundException) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt index cababaf04..6cd590d7e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt @@ -48,11 +48,12 @@ class MediaPreviewAdapter( val addFocusId = 2 val editImageId = 3 val removeId = 4 - if (item.state != ComposeActivity.QueuedMedia.State.PUBLISHED) { - // Already-published items can't have their metadata edited - popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) - if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) { - popup.menu.add(0, addFocusId, 0, R.string.action_set_focus) + + popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) + if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) { + popup.menu.add(0, addFocusId, 0, R.string.action_set_focus) + if (item.state != ComposeActivity.QueuedMedia.State.PUBLISHED) { + // Already-published items can't be edited popup.menu.add(0, editImageId, 0, R.string.action_edit_image) } } @@ -89,10 +90,11 @@ class MediaPreviewAdapter( val imageView = holder.progressImageView val focus = item.focus - if (focus != null) + if (focus != null) { imageView.setFocalPoint(focus) - else + } else { imageView.removeFocalPoint() // Probably unnecessary since we have no UI for removal once added. + } var glide = Glide.with(holder.itemView.context) .load(item.uri) @@ -100,8 +102,9 @@ class MediaPreviewAdapter( .dontAnimate() .centerInside() - if (focus != null) + if (focus != null) { glide = glide.addListener(imageView) + } glide.into(imageView) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt index d7ddf8722..0420f43d7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt @@ -159,7 +159,6 @@ class MediaUploader @Inject constructor( try { when (inUri.scheme) { ContentResolver.SCHEME_CONTENT -> { - mimeType = contentResolver.getType(uri) val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp") @@ -278,7 +277,8 @@ class MediaUploader @Inject constructor( var lastProgress = -1 val fileBody = ProgressRequestBody( - stream!!, media.mediaSize, + stream!!, + media.mediaSize, mimeType.toMediaTypeOrNull()!! ) { percentage -> if (percentage != lastProgress) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt index 005e67297..2d76e2d03 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt @@ -35,7 +35,6 @@ fun showAddPollDialog( maxDuration: Int, onUpdatePoll: (NewPoll) -> Unit ) { - val binding = DialogAddPollBinding.inflate(LayoutInflater.from(context)) val dialog = AlertDialog.Builder(context) @@ -63,7 +62,7 @@ fun showAddPollDialog( var durations = context.resources.getIntArray(R.array.poll_duration_values).toList() val durationLabels = context.resources.getStringArray(R.array.poll_duration_names).filterIndexed { index, _ -> durations[index] in minDuration..maxDuration } binding.pollDurationSpinner.adapter = ArrayAdapter(context, android.R.layout.simple_spinner_item, durationLabels).apply { - setDropDownViewResource(R.layout.support_simple_spinner_dropdown_item) + setDropDownViewResource(androidx.appcompat.R.layout.support_simple_spinner_dropdown_item) } durations = durations.filter { it in minDuration..maxDuration } @@ -76,8 +75,10 @@ fun showAddPollDialog( } } + val DAY_SECONDS = 60 * 60 * 24 + val desiredDuration = poll?.expiresIn ?: DAY_SECONDS val pollDurationId = durations.indexOfLast { - it <= (poll?.expiresIn ?: 0) + it <= desiredDuration } binding.pollDurationSpinner.setSelection(pollDurationId) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt index 3640ffa97..ded1b7cfd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt @@ -19,11 +19,11 @@ import android.text.InputFilter import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.widget.doOnTextChanged import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAddPollOptionBinding import com.keylesspalace.tusky.util.BindingHolder -import com.keylesspalace.tusky.util.onTextChanged import com.keylesspalace.tusky.util.visible class AddPollOptionsAdapter( @@ -46,7 +46,7 @@ class AddPollOptionsAdapter( val holder = BindingHolder(binding) binding.optionEditText.filters = arrayOf(InputFilter.LengthFilter(maxOptionLength)) - binding.optionEditText.onTextChanged { s, _, _, _ -> + binding.optionEditText.doOnTextChanged { s, _, _, _ -> val pos = holder.bindingAdapterPosition if (pos != RecyclerView.NO_POSITION) { options[pos] = s.toString() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt index d5ece95f4..f6f8a495e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt @@ -21,75 +21,55 @@ import android.graphics.drawable.Drawable import android.net.Uri import android.os.Bundle import android.text.InputFilter -import android.text.InputType import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.widget.EditText -import android.widget.LinearLayout import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment -import at.connyduck.sparkbutton.helpers.Utils import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition -import com.github.chrisbanes.photoview.PhotoView import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.DialogImageDescriptionBinding // https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32 private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500 class CaptionDialog : DialogFragment() { - private lateinit var listener: Listener private lateinit var input: EditText override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val context = requireContext() - val dialogLayout = LinearLayout(context) - val padding = Utils.dpToPx(context, 8) - dialogLayout.setPadding(padding, padding, padding, padding) - dialogLayout.orientation = LinearLayout.VERTICAL - val imageView = PhotoView(context).apply { - maximumScale = 6f - } + val binding = DialogImageDescriptionBinding.inflate(layoutInflater) - val margin = Utils.dpToPx(context, 4) - dialogLayout.addView(imageView) - (imageView.layoutParams as LinearLayout.LayoutParams).weight = 1f - imageView.layoutParams.height = 0 - (imageView.layoutParams as LinearLayout.LayoutParams).setMargins(0, margin, 0, 0) + input = binding.imageDescriptionText + val imageView = binding.imageDescriptionView + imageView.maximumScale = 6f - input = EditText(context) input.hint = resources.getQuantityString( R.plurals.hint_describe_for_visually_impaired, - MEDIA_DESCRIPTION_CHARACTER_LIMIT, MEDIA_DESCRIPTION_CHARACTER_LIMIT + MEDIA_DESCRIPTION_CHARACTER_LIMIT, + MEDIA_DESCRIPTION_CHARACTER_LIMIT ) - dialogLayout.addView(input) - (input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin) - input.setLines(2) - input.inputType = ( - InputType.TYPE_CLASS_TEXT - or InputType.TYPE_TEXT_FLAG_MULTI_LINE - or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES - ) input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT)) input.setText(arguments?.getString(EXISTING_DESCRIPTION_ARG)) val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId") val dialog = AlertDialog.Builder(context) - .setView(dialogLayout) + .setView(binding.root) .setPositiveButton(android.R.string.ok) { _, _ -> listener.onUpdateDescription(localId, input.text.toString()) } .setNegativeButton(android.R.string.cancel, null) .create() - isCancelable = false + isCancelable = true val window = dialog.window window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) @@ -105,7 +85,7 @@ class CaptionDialog : DialogFragment() { override fun onResourceReady( resource: Drawable, - transition: Transition?, + transition: Transition? ) { imageView.setImageDrawable(resource) } @@ -122,7 +102,7 @@ class CaptionDialog : DialogFragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?, + savedInstanceState: Bundle? ): View? { savedInstanceState?.getString(DESCRIPTION_KEY)?.let { input.setText(it) @@ -143,12 +123,12 @@ class CaptionDialog : DialogFragment() { fun newInstance( localId: Int, existingDescription: String?, - previewUri: Uri, + previewUri: Uri ) = CaptionDialog().apply { arguments = bundleOf( LOCAL_ID_ARG to localId, EXISTING_DESCRIPTION_ARG to existingDescription, - PREVIEW_URI_ARG to previewUri, + PREVIEW_URI_ARG to previewUri ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt index 4764ec544..c159c41d6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt @@ -21,7 +21,6 @@ import android.graphics.drawable.Drawable import android.net.Uri import android.view.WindowManager import android.widget.FrameLayout -import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope @@ -31,7 +30,6 @@ import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target -import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.DialogFocusBinding import com.keylesspalace.tusky.entity.Attachment.Focus import kotlinx.coroutines.launch @@ -39,7 +37,7 @@ import kotlinx.coroutines.launch fun T.makeFocusDialog( existingFocus: Focus?, previewUri: Uri, - onUpdateFocus: suspend (Focus) -> Boolean + onUpdateFocus: suspend (Focus) -> Unit ) where T : Activity, T : LifecycleOwner { val focus = existingFocus ?: Focus(0.0f, 0.0f) // Default to center @@ -79,9 +77,7 @@ fun T.makeFocusDialog( val okListener = { dialog: DialogInterface, _: Int -> lifecycleScope.launch { - if (!onUpdateFocus(dialogBinding.focusIndicator.getFocus())) { - showFailedFocusMessage() - } + onUpdateFocus(dialogBinding.focusIndicator.getFocus()) } dialog.dismiss() } @@ -99,7 +95,3 @@ fun T.makeFocusDialog( dialog.show() } - -private fun Activity.showFailedFocusMessage() { - Toast.makeText(this, R.string.error_failed_set_focus, Toast.LENGTH_SHORT).show() -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt index 9a3e4b00a..6e0b83dc5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt @@ -27,14 +27,16 @@ class FocusIndicatorView fun setImageSize(width: Int, height: Int) { this.imageSize = Point(width, height) - if (focus != null) + if (focus != null) { invalidate() + } } fun setFocus(focus: Attachment.Focus) { this.focus = focus - if (imageSize != null) + if (imageSize != null) { invalidate() + } } // Assumes setFocus called first @@ -46,8 +48,9 @@ class FocusIndicatorView // so base it on the view width/height whenever the first access occurs. private fun getCircleRadius(): Float { val circleRadius = this.circleRadius - if (circleRadius != null) + if (circleRadius != null) { return circleRadius + } val newCircleRadius = min(this.width, this.height).toFloat() / 4.0f this.circleRadius = newCircleRadius return newCircleRadius @@ -67,12 +70,11 @@ class FocusIndicatorView @SuppressLint("ClickableViewAccessibility") // Android Studio wants us to implement PerformClick for accessibility, but that unfortunately cannot be made meaningful for this widget. override fun onTouchEvent(event: MotionEvent): Boolean { - if (event.actionMasked == MotionEvent.ACTION_CANCEL) + if (event.actionMasked == MotionEvent.ACTION_CANCEL) { return false + } - val imageSize = this.imageSize - if (imageSize == null) - return false + val imageSize = this.imageSize ?: return false // Convert touch xy to point inside image focus = Attachment.Focus(axisToFocus(event.x, imageSize.x, this.width), -axisToFocus(event.y, imageSize.y, this.height)) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt index a3a6078cd..653a91e51 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt @@ -48,7 +48,6 @@ class TootButton fun setStatusVisibility(visibility: Status.Visibility) { if (!smallStyle) { - icon = when (visibility) { Status.Visibility.PUBLIC -> { setText(R.string.action_send_public) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index 190de74c2..af9ff8c47 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -64,9 +64,10 @@ data class ConversationAccountEntity( localUsername = localUsername, username = username, displayName = displayName, + note = "", url = "", avatar = avatar, - emojis = emojis, + emojis = emojis ) } } @@ -96,7 +97,7 @@ data class ConversationStatusEntity( val collapsed: Boolean, val muted: Boolean, val poll: Poll?, - val language: String?, + val language: String? ) { fun toViewData(): StatusViewData.Concrete { @@ -130,6 +131,7 @@ data class ConversationStatusEntity( poll = poll, card = null, language = language, + filtered = null, quote = null, ), isExpanded = expanded, @@ -146,7 +148,7 @@ fun TimelineAccount.toEntity() = username = username, displayName = name, avatar = avatar, - emojis = emojis ?: emptyList() + emojis = emojis.orEmpty() ) fun Status.toEntity( @@ -178,7 +180,7 @@ fun Status.toEntity( collapsed = contentCollapsed, muted = muted ?: false, poll = poll, - language = language, + language = language ) fun Conversation.toEntity( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt index 04e69d4c4..b197084df 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt @@ -87,6 +87,6 @@ fun StatusViewData.Concrete.toConversationStatusEntity( collapsed = collapsed, muted = muted, poll = poll, - language = status.language, + language = status.language ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java index fc561d403..4b4c2c5e9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -36,7 +36,6 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.SmartLengthInputFilter; import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.viewdata.PollViewDataKt; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.util.List; @@ -110,9 +109,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { setupButtons(listener, account.getId(), statusViewData.getContent().toString(), false, statusDisplayOptions); - setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(), - status.getMentions(), status.getTags(), status.getEmojis(), - PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener); + setSpoilerAndContent(statusViewData, statusDisplayOptions, listener); setConversationName(conversation.getAccounts()); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index dbe5b9238..a3ad1d7cc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -17,13 +17,18 @@ package com.keylesspalace.tusky.components.conversation import android.os.Bundle import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu +import androidx.core.view.MenuProvider import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.paging.LoadState import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration @@ -31,7 +36,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import at.connyduck.sparkbutton.helpers.Utils -import autodispose2.androidx.lifecycle.autoDispose +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.adapter.StatusBaseViewHolder @@ -53,7 +58,10 @@ import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +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.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -62,7 +70,12 @@ import javax.inject.Inject import kotlin.time.DurationUnit import kotlin.time.toDuration -class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment { +class ConversationsFragment : + SFragment(), + StatusActionListener, + Injectable, + ReselectableFragment, + MenuProvider { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -83,6 +96,8 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) val statusDisplayOptions = StatusDisplayOptions( @@ -96,7 +111,10 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res confirmFavourites = preferences.getBoolean("confirmFavourites", false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), - quoteEnabled = accountManager.activeAccount?.domain in CAN_USE_QUOTE_ID, + showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler, + quoteEnabled = accountManager.activeAccount?.domain in CAN_USE_QUOTE_ID, ) adapter = ConversationAdapter(statusDisplayOptions, this) @@ -125,9 +143,13 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res binding.statusView.show() if ((loadState.refresh as LoadState.Error).error is IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null) + binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { + refreshContent() + } } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null) + binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { + refreshContent() + } } } is LoadState.Loading -> { @@ -173,22 +195,48 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } } - lifecycleScope.launchWhenResumed { - val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) - while (!useAbsoluteTime) { - adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED)) - delay(1.toDuration(DurationUnit.MINUTES)) + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) + while (!useAbsoluteTime) { + adapter.notifyItemRangeChanged( + 0, + adapter.itemCount, + listOf(StatusBaseViewHolder.Key.KEY_CREATED) + ) + delay(1.toDuration(DurationUnit.MINUTES)) + } } } - eventHub.events - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_DESTROY) - .subscribe { event -> + lifecycleScope.launch { + eventHub.events.collect { event -> if (event is PreferenceChangedEvent) { onPreferenceChanged(event.preferenceKey) } } + } + } + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.fragment_conversations, menu) + menu.findItem(R.id.action_refresh)?.apply { + icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_refresh -> { + binding.swipeRefreshLayout.isRefreshing = true + refreshContent() + true + } + else -> false + } } private fun setupRecyclerView() { @@ -202,10 +250,12 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry)) } + private fun refreshContent() { + adapter.refresh() + } + private fun initSwipeToRefresh() { - binding.swipeRefreshLayout.setOnRefreshListener { - adapter.refresh() - } + binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() } binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) } @@ -317,6 +367,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } } + override fun clearWarningAction(position: Int) { + } + override fun onReselect() { if (isAdded) { binding.recyclerView.layoutManager?.scrollToPosition(0) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt index 921d694b2..b00c99a95 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt @@ -15,7 +15,7 @@ import retrofit2.HttpException class ConversationsRemoteMediator( private val api: MastodonApi, private val db: AppDatabase, - accountManager: AccountManager, + accountManager: AccountManager ) : RemoteMediator() { private var nextKey: String? = null @@ -28,7 +28,6 @@ class ConversationsRemoteMediator( loadType: LoadType, state: PagingState ): MediatorResult { - if (loadType == LoadType.PREPEND) { return MediatorResult.Success(endOfPaginationReached = true) } @@ -47,7 +46,6 @@ class ConversationsRemoteMediator( } db.withTransaction { - if (loadType == LoadType.REFRESH) { db.conversationDao().deleteForAccount(activeAccount.id) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt index 4e140b761..78bb7c8e5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt @@ -23,6 +23,7 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import androidx.paging.map +import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi @@ -30,7 +31,6 @@ import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.EmptyPagingSource import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import kotlinx.coroutines.rx3.await import javax.inject.Inject class ConversationsViewModel @Inject constructor( @@ -61,51 +61,47 @@ class ConversationsViewModel @Inject constructor( fun favourite(favourite: Boolean, conversation: ConversationViewData) { viewModelScope.launch { - try { - timelineCases.favourite(conversation.lastStatus.id, favourite).await() - + timelineCases.favourite(conversation.lastStatus.id, favourite).fold({ val newConversation = conversation.toEntity( accountId = accountManager.activeAccount!!.id, favourited = favourite ) saveConversationToDb(newConversation) - } catch (e: Exception) { + }, { e -> Log.w(TAG, "failed to favourite status", e) - } + }) } } fun bookmark(bookmark: Boolean, conversation: ConversationViewData) { viewModelScope.launch { - try { - timelineCases.bookmark(conversation.lastStatus.id, bookmark).await() - + timelineCases.bookmark(conversation.lastStatus.id, bookmark).fold({ val newConversation = conversation.toEntity( accountId = accountManager.activeAccount!!.id, bookmarked = bookmark ) saveConversationToDb(newConversation) - } catch (e: Exception) { + }, { e -> Log.w(TAG, "failed to bookmark status", e) - } + }) } } fun voteInPoll(choices: List, conversation: ConversationViewData) { viewModelScope.launch { - try { - val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices).await() - val newConversation = conversation.toEntity( - accountId = accountManager.activeAccount!!.id, - poll = poll - ) + timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices) + .fold({ poll -> + val newConversation = conversation.toEntity( + accountId = accountManager.activeAccount!!.id, + poll = poll + ) - saveConversationToDb(newConversation) - } catch (e: Exception) { - Log.w(TAG, "failed to vote in poll", e) - } + saveConversationToDb(newConversation) + }, { e -> + Log.w(TAG, "failed to vote in poll", e) + }) } } @@ -160,7 +156,7 @@ class ConversationsViewModel @Inject constructor( timelineCases.muteConversation( conversation.lastStatus.id, !(conversation.lastStatus.status.muted ?: false) - ).await() + ) val newConversation = conversation.toEntity( accountId = accountManager.activeAccount!!.id, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt index 5d2d852a5..2dc802e6a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt @@ -44,7 +44,7 @@ import javax.inject.Inject class DraftHelper @Inject constructor( val context: Context, - val okHttpClient: OkHttpClient, + private val okHttpClient: OkHttpClient, db: AppDatabase ) { @@ -66,7 +66,7 @@ class DraftHelper @Inject constructor( failedToSendAlert: Boolean, scheduledAt: String?, language: String?, - statusId: String?, + statusId: String? ) = withContext(Dispatchers.IO) { val externalFilesDir = context.getExternalFilesDir("Tusky") @@ -127,7 +127,7 @@ class DraftHelper @Inject constructor( failedToSendNew = failedToSendAlert, scheduledAt = scheduledAt, language = language, - statusId = statusId, + statusId = statusId ) draftDao.insertOrReplace(draft) @@ -140,7 +140,7 @@ class DraftHelper @Inject constructor( } } - suspend fun deleteDraftAndAttachments(draft: DraftEntity) { + private suspend fun deleteDraftAndAttachments(draft: DraftEntity) { deleteAttachments(draft) draftDao.delete(draft.id) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt index 98a288b42..2165f7e0c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt @@ -51,18 +51,20 @@ class DraftMediaAdapter( holder.imageView.clearFocus() holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp) } else { - if (attachment.focus != null) + if (attachment.focus != null) { holder.imageView.setFocalPoint(attachment.focus) - else + } else { holder.imageView.clearFocus() + } var glide = Glide.with(holder.itemView.context) .load(attachment.uri) .diskCacheStrategy(DiskCacheStrategy.NONE) .dontAnimate() .centerInside() - if (attachment.focus != null) + if (attachment.focus != null) { glide = glide.addListener(holder.imageView) + } glide.into(holder.imageView) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt index 18621fd3f..1edf354d6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt @@ -48,7 +48,6 @@ class DraftsAdapter( ) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { - val binding = ItemDraftBinding.inflate(LayoutInflater.from(parent.context), parent, false) val viewHolder = BindingHolder(binding) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt index 694398033..e748aebb9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt @@ -33,7 +33,7 @@ class DraftsViewModel @Inject constructor( val database: AppDatabase, val accountManager: AccountManager, val api: MastodonApi, - val draftHelper: DraftHelper + private val draftHelper: DraftHelper ) : ViewModel() { val drafts = Pager( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt new file mode 100644 index 000000000..3150d8d5d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt @@ -0,0 +1,274 @@ +package com.keylesspalace.tusky.components.filters + +import android.content.Context +import android.os.Bundle +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog +import androidx.core.view.size +import androidx.core.widget.doAfterTextChanged +import androidx.lifecycle.lifecycleScope +import com.google.android.material.chip.Chip +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.switchmaterial.SwitchMaterial +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.databinding.ActivityEditFilterBinding +import com.keylesspalace.tusky.databinding.DialogFilterBinding +import com.keylesspalace.tusky.di.ViewModelFactory +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 kotlinx.coroutines.launch +import java.util.Date +import javax.inject.Inject + +class EditFilterActivity : BaseActivity() { + @Inject + lateinit var api: MastodonApi + + @Inject + lateinit var eventHub: EventHub + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val binding by viewBinding(ActivityEditFilterBinding::inflate) + private val viewModel: EditFilterViewModel by viewModels { viewModelFactory } + + private lateinit var filter: Filter + private var originalFilter: Filter? = null + private lateinit var contextSwitches: Map + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + originalFilter = intent?.getParcelableExtra(FILTER_TO_EDIT) + filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN.action, listOf()) + binding.apply { + contextSwitches = mapOf( + filterContextHome to Filter.Kind.HOME, + filterContextNotifications to Filter.Kind.NOTIFICATIONS, + filterContextPublic to Filter.Kind.PUBLIC, + filterContextThread to Filter.Kind.THREAD, + filterContextAccount to Filter.Kind.ACCOUNT + ) + } + + setContentView(binding.root) + setSupportActionBar(binding.includedToolbar.toolbar) + supportActionBar?.run { + // Back button + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + setTitle( + if (originalFilter == null) { + R.string.filter_addition_title + } else { + R.string.filter_edit_title + } + ) + + binding.actionChip.setOnClickListener { showAddKeywordDialog() } + binding.filterSaveButton.setOnClickListener { saveChanges() } + for (switch in contextSwitches.keys) { + switch.setOnCheckedChangeListener { _, isChecked -> + val context = contextSwitches[switch]!! + if (isChecked) { + viewModel.addContext(context) + } else { + viewModel.removeContext(context) + } + validateSaveButton() + } + } + binding.filterTitle.doAfterTextChanged { editable -> + viewModel.setTitle(editable.toString()) + validateSaveButton() + } + binding.filterActionWarn.setOnCheckedChangeListener { _, checked -> + viewModel.setAction( + if (checked) { + Filter.Action.WARN + } else { + Filter.Action.HIDE + } + ) + } + binding.filterDurationSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + viewModel.setDuration( + if (originalFilter?.expiresAt == null) { + position + } else { + position - 1 + } + ) + } + + override fun onNothingSelected(parent: AdapterView<*>?) { + viewModel.setDuration(0) + } + } + validateSaveButton() + + if (originalFilter == null) { + binding.filterActionWarn.isChecked = true + } else { + loadFilter() + } + observeModel() + } + + private fun observeModel() { + lifecycleScope.launch { + viewModel.title.collect { title -> + if (title != binding.filterTitle.text.toString()) { + // We also get this callback when typing in the field, + // which messes with the cursor focus + binding.filterTitle.setText(title) + } + } + } + lifecycleScope.launch { + viewModel.keywords.collect { keywords -> + updateKeywords(keywords) + } + } + lifecycleScope.launch { + viewModel.contexts.collect { contexts -> + for (entry in contextSwitches) { + entry.key.isChecked = contexts.contains(entry.value) + } + } + } + lifecycleScope.launch { + viewModel.action.collect { action -> + when (action) { + Filter.Action.HIDE -> binding.filterActionHide.isChecked = true + else -> binding.filterActionWarn.isChecked = true + } + } + } + } + + // Populate the UI from the filter's members + private fun loadFilter() { + viewModel.load(filter) + if (filter.expiresAt != null) { + val durationNames = listOf(getString(R.string.duration_no_change)) + resources.getStringArray(R.array.filter_duration_names) + binding.filterDurationSpinner.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, durationNames) + } + } + + private fun updateKeywords(newKeywords: List) { + newKeywords.forEachIndexed { index, filterKeyword -> + val chip = binding.keywordChips.getChildAt(index).takeUnless { + it.id == R.id.actionChip + } as Chip? ?: Chip(this).apply { + setCloseIconResource(R.drawable.ic_cancel_24dp) + isCheckable = false + binding.keywordChips.addView(this, binding.keywordChips.size - 1) + } + + chip.text = if (filterKeyword.wholeWord) { + binding.root.context.getString( + R.string.filter_keyword_display_format, + filterKeyword.keyword + ) + } else { + filterKeyword.keyword + } + chip.isCloseIconVisible = true + chip.setOnClickListener { + showEditKeywordDialog(newKeywords[index]) + } + chip.setOnCloseIconClickListener { + viewModel.deleteKeyword(newKeywords[index]) + } + } + + while (binding.keywordChips.size - 1 > newKeywords.size) { + binding.keywordChips.removeViewAt(newKeywords.size) + } + + filter = filter.copy(keywords = newKeywords) + validateSaveButton() + } + + private fun showAddKeywordDialog() { + val binding = DialogFilterBinding.inflate(layoutInflater) + binding.phraseWholeWord.isChecked = true + AlertDialog.Builder(this) + .setTitle(R.string.filter_keyword_addition_title) + .setView(binding.root) + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.addKeyword( + FilterKeyword( + "", + binding.phraseEditText.text.toString(), + binding.phraseWholeWord.isChecked + ) + ) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun showEditKeywordDialog(keyword: FilterKeyword) { + val binding = DialogFilterBinding.inflate(layoutInflater) + binding.phraseEditText.setText(keyword.keyword) + binding.phraseWholeWord.isChecked = keyword.wholeWord + + AlertDialog.Builder(this) + .setTitle(R.string.filter_edit_keyword_title) + .setView(binding.root) + .setPositiveButton(R.string.filter_dialog_update_button) { _, _ -> + viewModel.modifyKeyword( + keyword, + keyword.copy( + keyword = binding.phraseEditText.text.toString(), + wholeWord = binding.phraseWholeWord.isChecked + ) + ) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun validateSaveButton() { + binding.filterSaveButton.isEnabled = viewModel.validate() + } + + private fun saveChanges() { + // TODO use a progress bar here (see EditProfileActivity/activity_edit_profile.xml for example)? + + lifecycleScope.launch { + if (viewModel.saveChanges(this@EditFilterActivity)) { + finish() + } else { + Snackbar.make(binding.root, "Error saving filter '${viewModel.title.value}'", Snackbar.LENGTH_SHORT).show() + } + } + } + + companion object { + const val FILTER_TO_EDIT = "FilterToEdit" + + // Mastodon *stores* the absolute date in the filter, + // but create/edit take a number of seconds (relative to the time the operation is posted) + fun getSecondsForDurationIndex(index: Int, context: Context?, default: Date? = null): Int? { + return when (index) { + -1 -> if (default == null) { default } else { ((default.time - System.currentTimeMillis()) / 1000).toInt() } + 0 -> null + else -> context?.resources?.getIntArray(R.array.filter_duration_values)?.get(index) + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt new file mode 100644 index 000000000..d33031d65 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt @@ -0,0 +1,186 @@ +package com.keylesspalace.tusky.components.filters + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.fold +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.FilterKeyword +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext +import retrofit2.HttpException +import javax.inject.Inject + +class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub: EventHub) : ViewModel() { + private var originalFilter: Filter? = null + val title = MutableStateFlow("") + val keywords = MutableStateFlow(listOf()) + val action = MutableStateFlow(Filter.Action.WARN) + val duration = MutableStateFlow(0) + val contexts = MutableStateFlow(listOf()) + + fun load(filter: Filter) { + originalFilter = filter + title.value = filter.title + keywords.value = filter.keywords + action.value = filter.action + duration.value = if (filter.expiresAt == null) { + 0 + } else { + -1 + } + contexts.value = filter.kinds + } + + fun addKeyword(keyword: FilterKeyword) { + keywords.value += keyword + } + + fun deleteKeyword(keyword: FilterKeyword) { + keywords.value = keywords.value.filterNot { it == keyword } + } + + fun modifyKeyword(original: FilterKeyword, updated: FilterKeyword) { + val index = keywords.value.indexOf(original) + if (index >= 0) { + keywords.value = keywords.value.toMutableList().apply { + set(index, updated) + } + } + } + + fun setTitle(title: String) { + this.title.value = title + } + + fun setDuration(index: Int) { + duration.value = index + } + + fun setAction(action: Filter.Action) { + this.action.value = action + } + + fun addContext(context: Filter.Kind) { + if (!contexts.value.contains(context)) { + contexts.value += context + } + } + + fun removeContext(context: Filter.Kind) { + contexts.value = contexts.value.filter { it != context } + } + + fun validate(): Boolean { + return title.value.isNotBlank() && + keywords.value.isNotEmpty() && + contexts.value.isNotEmpty() + } + + suspend fun saveChanges(context: Context): Boolean { + val contexts = contexts.value.map { it.kind } + val title = title.value + val durationIndex = duration.value + val action = action.value.action + + return withContext(viewModelScope.coroutineContext) { + originalFilter?.let { filter -> + updateFilter(filter, title, contexts, action, durationIndex, context) + } ?: createFilter(title, contexts, action, durationIndex, context) + } + } + + private suspend fun createFilter(title: String, contexts: List, action: String, durationIndex: Int, context: Context): Boolean { + val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context) + api.createFilter( + title = title, + context = contexts, + filterAction = action, + expiresInSeconds = expiresInSeconds + ).fold( + { newFilter -> + // This is _terrible_, but the all-in-one update filter api Just Doesn't Work + return keywords.value.map { keyword -> + api.addFilterKeyword(filterId = newFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord) + }.none { it.isFailure } + }, + { throwable -> + return ( + throwable is HttpException && throwable.code() == 404 && + // Endpoint not found, fall back to v1 api + createFilterV1(contexts, expiresInSeconds) + ) + } + ) + } + + private suspend fun updateFilter(originalFilter: Filter, title: String, contexts: List, action: String, durationIndex: Int, context: Context): Boolean { + val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context) + api.updateFilter( + id = originalFilter.id, + title = title, + context = contexts, + filterAction = action, + expiresInSeconds = expiresInSeconds + ).fold( + { + // This is _terrible_, but the all-in-one update filter api Just Doesn't Work + val results = keywords.value.map { keyword -> + if (keyword.id.isEmpty()) { + api.addFilterKeyword(filterId = originalFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord) + } else { + api.updateFilterKeyword(keywordId = keyword.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord) + } + } + originalFilter.keywords.filter { keyword -> + // Deleted keywords + keywords.value.none { it.id == keyword.id } + }.map { api.deleteFilterKeyword(it.id) } + + return results.none { it.isFailure } + }, + { throwable -> + if (throwable is HttpException && throwable.code() == 404) { + // Endpoint not found, fall back to v1 api + if (updateFilterV1(contexts, expiresInSeconds)) { + return true + } + } + return false + } + ) + } + + private suspend fun createFilterV1(context: List, expiresInSeconds: Int?): Boolean { + return keywords.value.map { keyword -> + api.createFilterV1(keyword.keyword, context, false, keyword.wholeWord, expiresInSeconds) + }.none { it.isFailure } + } + + private suspend fun updateFilterV1(context: List, expiresInSeconds: Int?): Boolean { + val results = keywords.value.map { keyword -> + if (originalFilter == null) { + api.createFilterV1( + phrase = keyword.keyword, + context = context, + irreversible = false, + wholeWord = keyword.wholeWord, + expiresInSeconds = expiresInSeconds + ) + } else { + api.updateFilterV1( + id = originalFilter!!.id, + phrase = keyword.keyword, + context = context, + irreversible = false, + wholeWord = keyword.wholeWord, + expiresInSeconds = expiresInSeconds + ) + } + } + // Don't handle deleted keywords here because there's only one keyword per v1 filter anyway + + return results.none { it.isFailure } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt new file mode 100644 index 000000000..9230fdae2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt @@ -0,0 +1,113 @@ +package com.keylesspalace.tusky.components.filters + +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import androidx.lifecycle.lifecycleScope +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ActivityFiltersBinding +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.util.visible +import kotlinx.coroutines.launch +import javax.inject.Inject + +class FiltersActivity : BaseActivity(), FiltersListener { + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val binding by viewBinding(ActivityFiltersBinding::inflate) + private val viewModel: FiltersViewModel by viewModels { viewModelFactory } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(binding.root) + setSupportActionBar(binding.includedToolbar.toolbar) + supportActionBar?.run { + // Back button + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + binding.addFilterButton.setOnClickListener { + launchEditFilterActivity() + } + + binding.swipeRefreshLayout.setOnRefreshListener { loadFilters() } + binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + + setTitle(R.string.pref_title_timeline_filters) + } + + override fun onResume() { + super.onResume() + loadFilters() + observeViewModel() + } + + private fun observeViewModel() { + lifecycleScope.launch { + viewModel.state.collect { state -> + binding.progressBar.visible(state.loadingState == FiltersViewModel.LoadingState.LOADING) + binding.swipeRefreshLayout.isRefreshing = state.loadingState == FiltersViewModel.LoadingState.LOADING + binding.addFilterButton.visible(state.loadingState == FiltersViewModel.LoadingState.LOADED) + + when (state.loadingState) { + FiltersViewModel.LoadingState.INITIAL, FiltersViewModel.LoadingState.LOADING -> binding.messageView.hide() + FiltersViewModel.LoadingState.ERROR_NETWORK -> { + binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) { + loadFilters() + } + binding.messageView.show() + } + FiltersViewModel.LoadingState.ERROR_OTHER -> { + binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) { + loadFilters() + } + binding.messageView.show() + } + FiltersViewModel.LoadingState.LOADED -> { + if (state.filters.isEmpty()) { + binding.messageView.setup( + R.drawable.elephant_friend_empty, + R.string.message_empty, + null + ) + binding.messageView.show() + } else { + binding.messageView.hide() + binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters) + } + } + } + } + } + } + + private fun loadFilters() { + viewModel.load() + } + + private fun launchEditFilterActivity(filter: Filter? = null) { + val intent = Intent(this, EditFilterActivity::class.java).apply { + if (filter != null) { + putExtra(EditFilterActivity.FILTER_TO_EDIT, filter) + } + } + startActivity(intent) + overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) + } + + override fun deleteFilter(filter: Filter) { + viewModel.deleteFilter(filter, binding.root) + } + + override fun updateFilter(updatedFilter: Filter) { + launchEditFilterActivity(updatedFilter) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt new file mode 100644 index 000000000..f6e6791a7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt @@ -0,0 +1,52 @@ +package com.keylesspalace.tusky.components.filters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemRemovableBinding +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.getRelativeTimeSpanString + +class FiltersAdapter(val listener: FiltersListener, val filters: List) : + RecyclerView.Adapter>() { + + override fun getItemCount(): Int = filters.size + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { + return BindingHolder(ItemRemovableBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + + override fun onBindViewHolder(holder: BindingHolder, position: Int) { + val binding = holder.binding + val resources = binding.root.resources + val actions = resources.getStringArray(R.array.filter_actions) + val contexts = resources.getStringArray(R.array.filter_contexts) + + val filter = filters[position] + val context = binding.root.context + binding.textPrimary.text = if (filter.expiresAt == null) { + filter.title + } else { + context.getString( + R.string.filter_expiration_format, + filter.title, + getRelativeTimeSpanString(binding.root.context, filter.expiresAt.time, System.currentTimeMillis()) + ) + } + binding.textSecondary.text = context.getString( + R.string.filter_description_format, + actions.getOrNull(filter.action.ordinal - 1), + filter.context.map { contexts.getOrNull(Filter.Kind.from(it).ordinal) }.joinToString("/") + ) + + binding.delete.setOnClickListener { + listener.deleteFilter(filter) + } + + binding.root.setOnClickListener { + listener.updateFilter(filter) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersListener.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersListener.kt new file mode 100644 index 000000000..a102b0d69 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersListener.kt @@ -0,0 +1,8 @@ +package com.keylesspalace.tusky.components.filters + +import com.keylesspalace.tusky.entity.Filter + +interface FiltersListener { + fun deleteFilter(filter: Filter) + fun updateFilter(updatedFilter: Filter) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt new file mode 100644 index 000000000..e28d251b8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt @@ -0,0 +1,87 @@ +package com.keylesspalace.tusky.components.filters + +import android.view.View +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.fold +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import retrofit2.HttpException +import javax.inject.Inject + +class FiltersViewModel @Inject constructor( + private val api: MastodonApi, + private val eventHub: EventHub +) : ViewModel() { + + enum class LoadingState { + INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER + } + + data class State(val filters: List, val loadingState: LoadingState) + + val state: Flow get() = _state + private val _state = MutableStateFlow(State(emptyList(), LoadingState.INITIAL)) + + fun load() { + this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.LOADING) + + viewModelScope.launch { + api.getFilters().fold( + { filters -> + this@FiltersViewModel._state.value = State(filters, LoadingState.LOADED) + }, + { throwable -> + if (throwable is HttpException && throwable.code() == 404) { + api.getFiltersV1().fold( + { filters -> + this@FiltersViewModel._state.value = State(filters.map { it.toFilter() }, LoadingState.LOADED) + }, + { throwable -> + // TODO log errors (also below) + + this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER) + } + ) + this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER) + } else { + this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_NETWORK) + } + } + ) + } + } + + fun deleteFilter(filter: Filter, parent: View) { + viewModelScope.launch { + api.deleteFilter(filter.id).fold( + { + this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED) + for (context in filter.context) { + eventHub.dispatch(PreferenceChangedEvent(context)) + } + }, + { throwable -> + if (throwable is HttpException && throwable.code() == 404) { + api.deleteFilterV1(filter.id).fold( + { + this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED) + }, + { + Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() + } + ) + } else { + Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() + } + } + ) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt index 0b8e7a5c7..94a0b47ef 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt @@ -1,21 +1,30 @@ package com.keylesspalace.tusky.components.followedtags +import android.app.Dialog +import android.content.DialogInterface +import android.content.SharedPreferences import android.os.Bundle import android.util.Log +import android.widget.AutoCompleteTextView import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import at.connyduck.calladapter.networkresult.fold import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter import com.keylesspalace.tusky.databinding.ActivityFollowedTagsBinding import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.interfaces.HashtagActionListener import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding @@ -25,13 +34,19 @@ import kotlinx.coroutines.launch import java.io.IOException import javax.inject.Inject -class FollowedTagsActivity : BaseActivity(), HashtagActionListener { +class FollowedTagsActivity : + BaseActivity(), + HashtagActionListener, + ComposeAutoCompleteAdapter.AutocompletionProvider { @Inject lateinit var api: MastodonApi @Inject lateinit var viewModelFactory: ViewModelFactory + @Inject + lateinit var sharedPreferences: SharedPreferences + private val binding by viewBinding(ActivityFollowedTagsBinding::inflate) private val viewModel: FollowedTagsViewModel by viewModels { viewModelFactory } @@ -47,6 +62,11 @@ class FollowedTagsActivity : BaseActivity(), HashtagActionListener { setDisplayShowHomeEnabled(true) } + binding.fab.setOnClickListener { + val dialog: DialogFragment = FollowTagDialog.newInstance() + dialog.show(supportFragmentManager, "dialog") + } + setupAdapter().let { adapter -> setupRecyclerView(adapter) @@ -64,6 +84,19 @@ class FollowedTagsActivity : BaseActivity(), HashtagActionListener { binding.followedTagsView.layoutManager = LinearLayoutManager(this) binding.followedTagsView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL)) (binding.followedTagsView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + + val hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) + if (hideFab) { + binding.followedTagsView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + if (dy > 0 && binding.fab.isShown) { + binding.fab.hide() + } else if (dy < 0 && !binding.fab.isShown) { + binding.fab.show() + } + } + }) + } } private fun setupAdapter(): FollowedTagsAdapter { @@ -89,11 +122,15 @@ class FollowedTagsActivity : BaseActivity(), HashtagActionListener { } } - private fun follow(tagName: String, position: Int) { + private fun follow(tagName: String, position: Int = -1) { lifecycleScope.launch { api.followTag(tagName).fold( { - viewModel.tags.add(position, it) + if (position == -1) { + viewModel.tags.add(it) + } else { + viewModel.tags.add(position, it) + } viewModel.currentSource?.invalidate() }, { @@ -142,7 +179,41 @@ class FollowedTagsActivity : BaseActivity(), HashtagActionListener { } } + override fun search(token: String): List { + return viewModel.searchAutocompleteSuggestions(token) + } + companion object { const val TAG = "FollowedTagsActivity" } + + class FollowTagDialog : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val layout = layoutInflater.inflate(R.layout.dialog_follow_hashtag, null) + val autoCompleteTextView = layout.findViewById(R.id.hashtag)!! + autoCompleteTextView.setAdapter( + ComposeAutoCompleteAdapter( + requireActivity() as FollowedTagsActivity, + animateAvatar = false, + animateEmojis = false, + showBotBadge = false + ) + ) + + return AlertDialog.Builder(requireActivity()) + .setTitle(R.string.dialog_follow_hashtag_title) + .setView(layout) + .setPositiveButton(android.R.string.ok) { _, _ -> + (requireActivity() as FollowedTagsActivity).follow( + autoCompleteTextView.text.toString().removePrefix("#") + ) + } + .setNegativeButton(android.R.string.cancel) { _: DialogInterface, _: Int -> } + .create() + } + + companion object { + fun newInstance(): FollowTagDialog = FollowTagDialog() + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt index 365900886..4cdc9f97a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt @@ -13,7 +13,7 @@ import com.keylesspalace.tusky.util.BindingHolder class FollowedTagsAdapter( private val actionListener: HashtagActionListener, - private val viewModel: FollowedTagsViewModel, + private val viewModel: FollowedTagsViewModel ) : PagingDataAdapter>(STRING_COMPARATOR) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder = BindingHolder(ItemFollowedHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false)) @@ -22,7 +22,7 @@ class FollowedTagsAdapter( viewModel.tags[position].let { tag -> holder.itemView.findViewById(R.id.followed_tag).text = tag.name holder.itemView.findViewById(R.id.followed_tag_unfollow).setOnClickListener { - actionListener.unfollow(tag.name, position) + actionListener.unfollow(tag.name, holder.bindingAdapterPosition) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsRemoteMediator.kt index 649ca583e..00239a75f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsRemoteMediator.kt @@ -13,7 +13,7 @@ import retrofit2.Response @OptIn(ExperimentalPagingApi::class) class FollowedTagsRemoteMediator( private val api: MastodonApi, - private val viewModel: FollowedTagsViewModel, + private val viewModel: FollowedTagsViewModel ) : RemoteMediator() { override suspend fun load( loadType: LoadType, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt index efe5661a9..1a1b794bb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt @@ -1,18 +1,22 @@ package com.keylesspalace.tusky.components.followedtags +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn +import at.connyduck.calladapter.networkresult.fold +import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter +import com.keylesspalace.tusky.components.search.SearchType import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.network.MastodonApi import javax.inject.Inject -class FollowedTagsViewModel @Inject constructor ( - api: MastodonApi +class FollowedTagsViewModel @Inject constructor( + private val api: MastodonApi ) : ViewModel(), Injectable { val tags: MutableList = mutableListOf() var nextKey: String? = null @@ -28,6 +32,20 @@ class FollowedTagsViewModel @Inject constructor ( ).also { source -> currentSource = source } - }, + } ).flow.cachedIn(viewModelScope) + + fun searchAutocompleteSuggestions(token: String): List { + return api.searchSync(query = token, type = SearchType.Hashtag.apiParameter, limit = 10) + .fold({ searchResult -> + searchResult.hashtags.map { ComposeAutoCompleteAdapter.AutocompleteResult.HashtagResult(it.name) } + }, { e -> + Log.e(TAG, "Autocomplete search for $token failed.", e) + emptyList() + }) + } + + companion object { + private const val TAG = "FollowedTagsViewModel" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt index 509c9561d..13d8f2d83 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt @@ -30,8 +30,9 @@ class DomainMutesAdapter( override fun getItemCount(): Int { var count = instances.size - if (bottomLoading) + if (bottomLoading) { ++count + } return count } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt index ccfe52b3c..1e4925a51 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt @@ -5,9 +5,11 @@ import android.util.Log import android.view.View import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import at.connyduck.calladapter.networkresult.fold import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from import autodispose2.autoDispose import com.google.android.material.snackbar.Snackbar @@ -23,9 +25,7 @@ import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.view.EndlessOnScrollListener import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response +import kotlinx.coroutines.launch import java.io.IOException import javax.inject.Inject @@ -64,39 +64,25 @@ class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectab } override fun mute(mute: Boolean, instance: String, position: Int) { - if (mute) { - api.blockDomain(instance).enqueue(object : Callback { - override fun onFailure(call: Call, t: Throwable) { - Log.e(TAG, "Error muting domain $instance") - } - - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - adapter.addItem(instance) - } else { - Log.e(TAG, "Error muting domain $instance") - } - } - }) - } else { - api.unblockDomain(instance).enqueue(object : Callback { - override fun onFailure(call: Call, t: Throwable) { - Log.e(TAG, "Error unmuting domain $instance") - } - - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - adapter.removeItem(position) - Snackbar.make(binding.recyclerView, getString(R.string.confirmation_domain_unmuted, instance), Snackbar.LENGTH_LONG) - .setAction(R.string.action_undo) { - mute(true, instance, position) - } - .show() - } else { - Log.e(TAG, "Error unmuting domain $instance") - } - } - }) + viewLifecycleOwner.lifecycleScope.launch { + if (mute) { + api.blockDomain(instance).fold({ + adapter.addItem(instance) + }, { e -> + Log.e(TAG, "Error muting domain $instance", e) + }) + } else { + api.unblockDomain(instance).fold({ + adapter.removeItem(position) + Snackbar.make(binding.recyclerView, getString(R.string.confirmation_domain_unmuted, instance), Snackbar.LENGTH_LONG) + .setAction(R.string.action_undo) { + mute(true, instance, position) + } + .show() + }, { e -> + Log.e(TAG, "Error unmuting domain $instance", e) + }) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt index c5ad6e79c..da6b31a5c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt @@ -97,7 +97,8 @@ class LoginActivity : BaseActivity(), Injectable { } preferences = getSharedPreferences( - getString(R.string.preferences_file_key), Context.MODE_PRIVATE + getString(R.string.preferences_file_key), + Context.MODE_PRIVATE ) binding.loginButton.setOnClickListener { onLoginClick(true) } @@ -161,8 +162,11 @@ class LoginActivity : BaseActivity(), Injectable { lifecycleScope.launch { mastodonApi.authenticateApp( - domain, getString(R.string.app_name), oauthRedirectUri, - OAUTH_SCOPES, getString(R.string.tusky_website) + domain, + getString(R.string.app_name), + oauthRedirectUri, + OAUTH_SCOPES, + getString(R.string.tusky_website) ).fold( { credentials -> // Before we open browser page we save the data. @@ -266,7 +270,12 @@ class LoginActivity : BaseActivity(), Injectable { setLoading(true) mastodonApi.fetchOAuthToken( - domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code" + domain, + clientId, + clientSecret, + oauthRedirectUri, + code, + "authorization_code" ).fold( { accessToken -> fetchAccountDetails(accessToken, domain, clientId, clientSecret) @@ -286,7 +295,6 @@ class LoginActivity : BaseActivity(), Injectable { clientId: String, clientSecret: String ) { - mastodonApi.accountVerifyCredentials( domain = domain, auth = "Bearer ${accessToken.accessToken}" @@ -342,6 +350,7 @@ class LoginActivity : BaseActivity(), Injectable { const val MODE_DEFAULT = 0 const val MODE_ADDITIONAL_LOGIN = 1 + // "Migration" is used to update the OAuth scope granted to the client const val MODE_MIGRATION = 2 diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt index b69f81e76..367eab5b8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt @@ -85,7 +85,7 @@ class OauthLogin : ActivityResultContract() { data class LoginData( val domain: String, val url: Uri, - val oauthRedirectUrl: Uri, + val oauthRedirectUrl: Uri ) : Parcelable sealed class LoginResult : Parcelable { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt new file mode 100644 index 000000000..70f564c0c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt @@ -0,0 +1,111 @@ +/* + * 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 . + */ + +package com.keylesspalace.tusky.components.notifications + +import androidx.recyclerview.widget.RecyclerView +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 linkListener: LinkListener +) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { + private val avatarRadius42dp = itemView.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_42dp + ) + + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + // Skip updates with payloads. That indicates a timestamp update, and + // this view does not have timestamps. + if (!payloads.isNullOrEmpty()) return + + setMessage( + viewData.account, + viewData.type === Notification.Type.SIGN_UP, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.animateEmojis + ) + setupButtons(notificationActionListener, viewData.account.id) + } + + private fun setMessage( + account: TimelineAccount, + isSignUp: Boolean, + animateAvatars: Boolean, + animateEmojis: Boolean + ) { + val context = binding.notificationText.context + val format = + context.getString( + if (isSignUp) { + R.string.notification_sign_up_format + } else { + R.string.notification_follow_format + } + ) + val wrappedDisplayName = account.name.unicodeWrap() + val wholeMessage = String.format(format, wrappedDisplayName) + val emojifiedMessage = + wholeMessage.emojify( + account.emojis, + binding.notificationText, + animateEmojis + ) + binding.notificationText.text = emojifiedMessage + val username = context.getString(R.string.post_username_format, account.username) + binding.notificationUsername.text = username + val emojifiedDisplayName = wrappedDisplayName.emojify( + account.emojis, + binding.notificationUsername, + animateEmojis + ) + binding.notificationDisplayName.text = emojifiedDisplayName + loadAvatar( + account.avatar, + binding.notificationAvatar, + avatarRadius42dp, + animateAvatars + ) + + 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) { + binding.root.setOnClickListener { listener.onViewAccount(accountId) } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt index 1af9b3d7d..959bce9b5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt @@ -1,7 +1,10 @@ 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 @@ -9,7 +12,17 @@ import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.isLessThan import javax.inject.Inject +import kotlin.math.min +/** + * Fetch Mastodon notifications and show Android notifications, with summaries, for them. + * + * Should only be called by a worker thread. + * + * @see NotificationWorker + * @see Background worker + */ +@WorkerThread class NotificationFetcher @Inject constructor( private val mastodonApi: MastodonApi, private val accountManager: AccountManager, @@ -19,46 +32,130 @@ class NotificationFetcher @Inject constructor( 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 + Thread.sleep(1000) + } + + 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 { + /** + * 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 fun fetchNewNotifications(account: AccountEntity): List { 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) + + // 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 + + val minId = 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") + val notifications = mastodonApi.notificationsWithAuth( authHeader, account.domain, - account.lastNotificationId + minId ).blockingGet() - val newId = account.lastNotificationId - var newestId = "" - val result = mutableListOf() - 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) - } + // Notifications are returned in order, most recent first. 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 result + + return notifications } private fun fetchMarker(authHeader: String, account: AccountEntity): Marker? { @@ -69,7 +166,7 @@ class NotificationFetcher @Inject constructor( 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 +175,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 } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java index b855049d8..638ab8e17 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java @@ -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,21 @@ 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.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,25 +60,20 @@ 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 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; @@ -84,7 +83,7 @@ public class NotificationHelper { */ 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"; @@ -127,51 +126,53 @@ public class NotificationHelper { */ 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. + *

+ * 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 +234,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. + *

+ * Notifications are sent to channels. Within each channel they may be grouped, and the group + * may have a summary. + *

+ * 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. + *

+ * The group key is the same as the channel ID. + *

+ * 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 Create a + * notification group + * @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> 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 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> channelGroup : channelGroups.entrySet()) { + String channelId = channelGroup.getKey(); + List 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 +376,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; } @@ -453,7 +536,6 @@ public class NotificationHelper { } notificationManager.createNotificationChannels(channels); - } } @@ -495,6 +577,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 +593,7 @@ public class NotificationHelper { ) .addTag(NOTIFICATION_PULL_TAG) .setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setInitialDelay(5, TimeUnit.MINUTES) .build(); workManager.enqueue(workRequest); @@ -516,31 +608,23 @@ public class NotificationHelper { 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); + if (account == null) return; + 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 +694,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 +712,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 notifications) { + if (notifications.size() > 3) { + int length = notifications.size(); + //notifications.get(0).