diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 000000000..eeff27df8 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,18 @@ +name: 'Setup build environment' +description: 'Sets up an environment for building Tusky' +runs: + using: "composite" + steps: + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Copy CI gradle.properties + shell: bash + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + + - name: Gradle Build Action + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} diff --git a/renovate.json b/.github/renovate.json similarity index 59% rename from renovate.json rename to .github/renovate.json index 8df673392..613fe2b5c 100644 --- a/renovate.json +++ b/.github/renovate.json @@ -7,11 +7,9 @@ { "groupName": "Kotlin", "groupSlug": "kotlin", - "matchPackagePrefixes": [ - "com.google.devtools.ksp" - ], - "matchPackagePatterns": [ - "org.jetbrains.kotlin.*" + "matchPackageNames": [ + "com.google.devtools.ksp{/,}**", + "/org.jetbrains.kotlin.*/" ] } ] diff --git a/.github/workflows/check-and-build.yml b/.github/workflows/check-and-build.yml new file mode 100644 index 000000000..6bd7308e9 --- /dev/null +++ b/.github/workflows/check-and-build.yml @@ -0,0 +1,27 @@ +name: Check and build + +on: + pull_request: + workflow_call: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup + uses: ./.github/actions/setup + + - name: ktlint + run: ./gradlew clean ktlintCheck + + - name: Regular lint + run: ./gradlew app:lintGreenDebug + + - name: Test + run: ./gradlew app:testGreenDebugUnitTest + + - name: Build + run: ./gradlew app:buildGreenDebug diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 479c5232f..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: CI - -on: - push: - tags: - - '*' - pull_request: - workflow_dispatch: - -jobs: - build: - name: Build - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - - - name: Gradle Wrapper Validation - uses: gradle/actions/wrapper-validation@v3 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Gradle Build Action - uses: gradle/gradle-build-action@v3 - with: - cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} - - - name: ktlint - run: ./gradlew clean ktlintCheck - - - name: Regular lint - run: ./gradlew app:lintGreenDebug - - - name: Test - run: ./gradlew app:testGreenDebugUnitTest - - - name: Build - run: ./gradlew app:buildGreenDebug diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml new file mode 100644 index 000000000..576601561 --- /dev/null +++ b/.github/workflows/deploy-release.yml @@ -0,0 +1,53 @@ +# When a tag is created, create a release build and upload it to Google Play + +name: Deploy release to Google Play + +on: + push: + tags: + - '*' + +jobs: + check-and-build: + uses: ./.github/workflows/check-and-build.yml + deploy: + runs-on: ubuntu-latest + needs: check-and-build + environment: Release + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup + uses: ./.github/actions/setup + + - name: Build Blue aab + run: ./gradlew app:bundleBlueRelease + + - uses: r0adkll/sign-android-release@f30bdd30588842ac76044ecdbd4b6d0e3e813478 + name: Sign Tusky Blue aab + id: sign_aab + with: + releaseDirectory: app/build/outputs/bundle/blueRelease + signingKeyBase64: ${{ secrets.KEYSTORE }} + alias: ${{ secrets.KEY_ALIAS }} + keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} + keyPassword: ${{ secrets.KEY_PASSWORD }} + + - name: Generate whatsnew + id: generate-whatsnew + run: | + mkdir whatsnew + cp $(find fastlane/metadata/android/en-US/changelogs | sort -n -k6 -t/ | tail -n 1) whatsnew/whatsnew-en-US + + - name: Upload AAB to Google Play + id: upload-release-asset-aab + uses: r0adkll/upload-google-play@v1.1.3 + with: + serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} + packageName: com.keylesspalace.tusky + releaseFiles: ${{steps.sign_aab.outputs.signedReleaseFile}} + track: internal + whatsNewDirectory: whatsnew + status: completed + mappingFile: app/build/outputs/mapping/blueRelease/mapping.txt diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml new file mode 100644 index 000000000..f5a5b3e87 --- /dev/null +++ b/.github/workflows/deploy-test.yml @@ -0,0 +1,64 @@ +# Deploy Tusky Nightly on each push to develop + +name: Deploy Tusky Nightly to Google Play + +on: + push: + branches: + - develop + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + +jobs: + check-and-build: + uses: ./.github/workflows/check-and-build.yml + deploy: + runs-on: ubuntu-latest + needs: check-and-build + environment: Test + env: + BUILD_NUMBER: ${{ github.run_number }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup + uses: ./.github/actions/setup + + - name: Set versionCode + run: | + export VERSION_CODE=$(( ${BUILD_NUMBER} + 11000 )) + sed -i'.original' -e "s/^\([[:space:]]*versionCode[[:space:]]*\)[0-9]*/\\1$VERSION_CODE/" app/build.gradle + + - name: Build Green aab + run: ./gradlew app:bundleGreenRelease + + - uses: r0adkll/sign-android-release@f30bdd30588842ac76044ecdbd4b6d0e3e813478 + name: Sign Tusky Green aab + id: sign_aab + with: + releaseDirectory: app/build/outputs/bundle/greenRelease + signingKeyBase64: ${{ secrets.KEYSTORE }} + alias: ${{ secrets.KEY_ALIAS }} + keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} + keyPassword: ${{ secrets.KEY_PASSWORD }} + + - name: Generate whatsnew + id: generate-whatsnew + run: | + mkdir whatsnew + git log -3 --pretty=%B | head -c 500 > whatsnew/whatsnew-en-US + + - name: Upload AAB to Google Play + id: upload-release-asset-aab + uses: r0adkll/upload-google-play@v1.1.3 + with: + serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} + packageName: com.keylesspalace.tusky.test + releaseFiles: ${{steps.sign_aab.outputs.signedReleaseFile}} + track: production + whatsNewDirectory: whatsnew + status: completed + mappingFile: app/build/outputs/mapping/greenRelease/mapping.txt diff --git a/.github/workflows/populate-gradle-build-cache.yml b/.github/workflows/populate-gradle-build-cache.yml deleted file mode 100644 index 0207cceb1..000000000 --- a/.github/workflows/populate-gradle-build-cache.yml +++ /dev/null @@ -1,34 +0,0 @@ -# Build the app on each push to `develop`, populating the build cache to speed -# up CI on PRs. - -name: Populate build cache - -on: - push: - branches: - - develop - -jobs: - build: - name: app:buildGreenDebug - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - - - name: Gradle Wrapper Validation - uses: gradle/actions/wrapper-validation@v3 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - uses: gradle/gradle-build-action@v3 - with: - cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} - - - name: Run app:buildGreenDebug - run: ./gradlew app:buildGreenDebug diff --git a/.gitignore b/.gitignore index b40257dd5..67ab69cfe 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ build /captures .externalNativeBuild app/release -app-release.apk \ No newline at end of file +app-release.apk +.kotlin diff --git a/CHANGELOG.md b/CHANGELOG.md index ab13d8e7c..e8eaae44d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,141 @@ ### Significant bug fixes +## v28.0 + +### New features and other improvements + +- Support for Android 15 and edge-to-edge mode https://github.com/tuskyapp/Tusky/pull/4897 +- Improves the reliability of push notifications https://github.com/tuskyapp/Tusky/pull/4896 https://github.com/tuskyapp/Tusky/pull/4883 +- Replies in timeline are now clearly marked as such by a text above them https://github.com/tuskyapp/Tusky/pull/4834 +- Several improvements to how notifications are rendered in the notifications tab https://github.com/tuskyapp/Tusky/pull/4929 + - support for the new Mastodon 4.3 notification types `severed_relationships` and `moderation_warning` + - The "unknown notification type" notification now shows the unknown type and a info dialog when you click it + - The account note is now shown again for follow request and follow notifications + - The icon for the " just posted" notification is now a bell instead of a home + - Adds a text above mention notifications that indicates if it is a (private) reply or (private) mention + - Follow requests won't be filtered by default in the notification tab. This change will only affect new logins and not existing ones. +- Link Preview Cards got a new design and now support the fediverse:creator feature https://github.com/tuskyapp/Tusky/pull/4782 +- The possible selections for mute durations are now 1 hour, 6 hours, 1 day, 7 days, 30 days and 180 days https://github.com/tuskyapp/Tusky/pull/4943 +- The rendering of trending tags has been improved https://github.com/tuskyapp/Tusky/pull/4889 https://github.com/tuskyapp/Tusky/pull/4924 +- The app will no longer make database queries on the main thread, which improves performance https://github.com/tuskyapp/Tusky/pull/4786 +- Wellbeing mode will no longer hide the "follows you" badge on profiles https://github.com/tuskyapp/Tusky/pull/4940 +- It is now possible to select boost visibility when the "Show confirmation before boosting" option is active https://github.com/tuskyapp/Tusky/pull/4944 + +### Significant bug fixes + +- Fixes a bug where more than 4 profile fields could not be edited (on instances that allow more than 4) https://github.com/tuskyapp/Tusky/commit/1157be18cf3bbd44426f4cdaae35e69b9f3cecca +- Fixes a bug where a dropdown was partially hidden by the keyboard https://github.com/tuskyapp/Tusky/pull/4913 +- Tusky side timeline filters apply to own posts again https://github.com/tuskyapp/Tusky/pull/4879 +- Fixes a bug where media previews would flicker when interacting with a post https://github.com/tuskyapp/Tusky/pull/4971 +- Tusky now ignores invalid publishing dates of preview cards that caused some posts not to load https://github.com/tuskyapp/Tusky/pull/4993 + +## v27.2 + +### Significant bug fixes + +- The title of a hashtag tab now shows the actual hashtags again (instead of just "Hashtags") https://github.com/tuskyapp/Tusky/pull/4868 +- Makes sure the background color of a dialogs is correct https://github.com/tuskyapp/Tusky/pull/4864 +- Fixes an issue where Tusky would freeze while loading a timeline gap https://github.com/tuskyapp/Tusky/pull/4865 + +## v27.1 + +### New features and other improvements + +- The width of the tab indicator has been increased https://github.com/tuskyapp/Tusky/pull/4849 + +### Significant bug fixes + +- Improves rendering of some animated custom emojis https://github.com/tuskyapp/Tusky/pull/4281 +- Fixes an issue where the input field for media descriptions was too small in some cases https://github.com/tuskyapp/Tusky/pull/4831 +- Fixes an issue where hashtags at the end of posts were duplicated https://github.com/tuskyapp/Tusky/pull/4845 +- Fixes an issue that prevented lists from being edited https://github.com/tuskyapp/Tusky/pull/4851 + +## v27.0 + +### New features and other improvements + +- Tusky has been redesigned with Material 3 https://github.com/tuskyapp/Tusky/pull/4637 https://github.com/tuskyapp/Tusky/pull/4673 +- Support for Notification Policies (Mastodon 4.3 feature) https://github.com/tuskyapp/Tusky/pull/4768 +- Hashtags at the end of posts are now shown in a separate bar https://github.com/tuskyapp/Tusky/pull/4761 +- Full support for folding devices https://github.com/tuskyapp/Tusky/pull/4689 +- Improved post rendering in some edge cases https://github.com/tuskyapp/Tusky/pull/4650 https://github.com/tuskyapp/Tusky/pull/4672 https://github.com/tuskyapp/Tusky/pull/4723 +- Descriptions can now be added to audio attachments https://github.com/tuskyapp/Tusky/pull/4711 +- The screen keyboard now pops up automatically when opening a dialog that contains a textfield https://github.com/tuskyapp/Tusky/pull/4667 + +### Significant bug fixes + +- fixes a bug where Tusky would drop your draft when switching apps https://github.com/tuskyapp/Tusky/pull/4685 https://github.com/tuskyapp/Tusky/pull/4813 https://github.com/tuskyapp/Tusky/pull/4818 +- fixes a bug where Tusky would drop media that is being added to a post https://github.com/tuskyapp/Tusky/pull/4662 +- fixes a bug that caused the login to fail in some cases https://github.com/tuskyapp/Tusky/pull/4704 + +## v26.2 + +### Significant bug fixes + +- Fixes a bug where Tusky would not correctly switch between accounts https://github.com/tuskyapp/Tusky/pull/4636 +- Fixes a crash when a status in a notification contains a reblog (happens when subscribed to a Friendica group) https://github.com/tuskyapp/Tusky/pull/4638 +- Long video descriptions can no longer cover the video controls https://github.com/tuskyapp/Tusky/pull/4632 +- Fixes a bug where Tusky's URL detection algorithm was different from Mastodon's https://github.com/tuskyapp/Tusky/pull/4642 + +## v26.1 + +### New features and other improvements + +- The "Reply privacy" account preference now has two additional options: "Match default post privacy" and "Direct". "Match default post privacy" is the default for new accounts. https://github.com/tuskyapp/Tusky/pull/4568 +- Tusky now includes ISRG root certificates to keep working on Android 7 and servers that use Let's Encrypt. https://github.com/tuskyapp/Tusky/pull/4609 +- The soft keyboard will now be hidden after performing a search. https://github.com/tuskyapp/Tusky/pull/4578 + +### Significant bug fixes + +- Fixes a bug where Tusky sometimes mixes up timelines and/or notifications of accounts. https://github.com/tuskyapp/Tusky/pull/4577 https://github.com/tuskyapp/Tusky/pull/4599 +- Fixes two bugs where Tusky would not provide the translation option even though the server is configured correctly. https://github.com/tuskyapp/Tusky/pull/4560 https://github.com/tuskyapp/Tusky/pull/4590 +- Fixes a rare bug where Tusky would sometimes randomly crash on startup. https://github.com/tuskyapp/Tusky/pull/4569 +- Fixes a bug where the timeline would randomly jump to the position of the last clicked "show more" placeholder when "Reading order" was set to "Oldest first". https://github.com/tuskyapp/Tusky/pull/4619 + +## v26.0 + +### New features and other improvements + +- The blue primary color that previously was the same for all themes is now slightly lighter in the dark theme and darker in the light theme for better contrast. + Consequently, the color that is used on top of the primary color (e.g. on buttons) is now dark instead of white in the dark theme. [PR#3921](https://github.com/tuskyapp/Tusky/pull/3921) [PR#4507](https://github.com/tuskyapp/Tusky/pull/4507) +- New account preference "default reply privacy". + Note that in contrast to the "default post privacy" this setting will not be synced with the server as Mastodon does not have this feature. [PR#4496](https://github.com/tuskyapp/Tusky/pull/4496) +- New preference "Show confirmation before following" [PR#4445](https://github.com/tuskyapp/Tusky/pull/4445) +- The notification tab is now cached on the device for better offline behavior. + Since it shares the cache with the home timeline, interactions with posts will now sync between those tabs more often than before. [PR#4026](https://github.com/tuskyapp/Tusky/pull/4026) +- Tusky will now only make one call to the server to check which version of the filters api is supported and cache the result instead of everytime filters are needed. [PR#4539](https://github.com/tuskyapp/Tusky/pull/4539) +- The "Hide compose button while scrolling" preference, which had the main purpose of making content behind the button accessible, has been removed and bottom padding added to all lists that could be obscured by buttons. [PR#4486](https://github.com/tuskyapp/Tusky/pull/4486) +- When viewing media of a translated post the media descriptions will now also be translated [PR#4463](https://github.com/tuskyapp/Tusky/pull/4463) +- The custom emojis in the emoji picker are now sorted by category [PR#4533](https://github.com/tuskyapp/Tusky/pull/4533) +- Various internal refactorings to improve performance and maintainability. + [PR#4515](https://github.com/tuskyapp/Tusky/pull/4515) + [PR#4502](https://github.com/tuskyapp/Tusky/pull/4502) + [PR#4472](https://github.com/tuskyapp/Tusky/pull/4472) + [PR#4470](https://github.com/tuskyapp/Tusky/pull/4470) + [PR#4443](https://github.com/tuskyapp/Tusky/pull/4443) + [PR#4441](https://github.com/tuskyapp/Tusky/pull/4441) + [PR#4461](https://github.com/tuskyapp/Tusky/pull/4461) + [PR#4447](https://github.com/tuskyapp/Tusky/pull/4447) + [PR#4411](https://github.com/tuskyapp/Tusky/pull/4411) + [PR#4413](https://github.com/tuskyapp/Tusky/pull/4413) + +### Significant bug fixes + +- Posts with null media focus values will no longer cause Tusky to show an error [PR#4462](https://github.com/tuskyapp/Tusky/pull/4462) +- A lot of other bugfixes, mostly smaller display bugs + [PR#4536](https://github.com/tuskyapp/Tusky/pull/4536) + [PR#4537](https://github.com/tuskyapp/Tusky/pull/4537) + [PR#4527](https://github.com/tuskyapp/Tusky/pull/4527) + [PR#4521](https://github.com/tuskyapp/Tusky/pull/4521) + [PR#4525](https://github.com/tuskyapp/Tusky/pull/4525) + [PR#4518](https://github.com/tuskyapp/Tusky/pull/4518) + [PR#4514](https://github.com/tuskyapp/Tusky/pull/4514) + [PR#4491](https://github.com/tuskyapp/Tusky/pull/4491) + [PR#4490](https://github.com/tuskyapp/Tusky/pull/4490) + [PR#4474](https://github.com/tuskyapp/Tusky/pull/4474) + [PR#4436](https://github.com/tuskyapp/Tusky/pull/4436) + ## v25.2 ### Significant bug fixes diff --git a/app/build.gradle b/app/build.gradle index 3002ee0f3..a62101c80 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,8 +1,8 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.google.ksp) + alias(libs.plugins.hilt.android) alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.kapt) alias(libs.plugins.kotlin.parcelize) } @@ -24,15 +24,15 @@ final def SUPPORT_ACCOUNT_URL = "https://social.chinwag.org/@ChinwagNews" final def REGISTER_ACCOUNT_URL = "https://chinwag.org/auth/sign_up" android { - compileSdk 34 + compileSdk 35 namespace "com.keylesspalace.tusky" defaultConfig { applicationId APP_ID namespace "com.keylesspalace.tusky" minSdk 24 targetSdk 35 - versionCode 93 - versionName "25.2-CW0" + versionCode 94 + versionName "28.0-CW0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true @@ -84,6 +84,7 @@ android { resValues true viewBinding true } + testOptions { unitTests { returnDefaultValues = true @@ -95,13 +96,9 @@ android { } } sourceSets { - androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) - } - - packagingOptions { - jniLibs { - useLegacyPackaging true - } + // workaround to have migrations available in unit tests + // https://github.com/robolectric/robolectric/issues/3928#issuecomment-395309991 + debug.assets.srcDirs += files("$projectDir/schemas".toString()) } // Exclude unneeded files added by libraries @@ -110,6 +107,12 @@ android { 'LICENSE_UNICODE', ] + packagingOptions { + jniLibs { + useLegacyPackaging true + } + } + bundle { language { // bundle all languages in every apk so the dynamic language switching works @@ -165,8 +168,10 @@ dependencies { implementation libs.bundles.glide ksp libs.glide.compiler - implementation libs.bundles.dagger - kapt libs.bundles.dagger.processors + implementation libs.hilt.android + ksp libs.hilt.compiler + implementation libs.androidx.hilt.work + ksp libs.androidx.hilt.compiler implementation libs.sparkbutton @@ -189,29 +194,8 @@ dependencies { testImplementation libs.bundles.mockito testImplementation libs.mockwebserver testImplementation libs.androidx.core.testing + testImplementation libs.androidx.room.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 - androidTestImplementation libs.androidx.test.junit -} - -// Work around warnings of: -// WARNING: Illegal reflective access by org.jetbrains.kotlin.kapt3.util.ModuleManipulationUtilsKt (file:/C:/Users/Andi/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-annotation-processing-gradle/1.8.22/28dab7e0ee9ce62c03bf97de3543c911dc653700/kotlin-annotation-processing-gradle-1.8.22.jar) to constructor com.sun.tools.javac.util.Context() -// See https://youtrack.jetbrains.com/issue/KT-30589/Kapt-An-illegal-reflective-access-operation-has-occurred -tasks.withType(org.jetbrains.kotlin.gradle.internal.KaptWithoutKotlincTask).configureEach { - kaptProcessJvmArgs.addAll([ - "--add-opens", "jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED"]) } diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index f77ebf931..fe77d2c8d 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -1,5 +1,5 @@ - + @@ -19,7 +19,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -53,14 +53,14 @@ + message="Overriding `@layout/exo_player_control_view` which is marked as private in androidx.media3:media3-ui:1.4.1. If deliberate, use tools:override="true", otherwise pick a different name."> + errorLine1=" <string name="notification_summary_report_format">%1$s · %2$d posts attached</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" <string name="pref_title_http_proxy_port_message">Port should be between %1$d and %2$d</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -241,7 +241,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -252,7 +252,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -263,7 +263,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -384,7 +384,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -428,7 +428,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -498,28 +498,6 @@ column="13"/> - - - - - - - - + errorLine1=" ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -747,7 +725,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -780,7 +758,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -872,312 +850,4 @@ column="9"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/lint.xml b/app/lint.xml index 88ad21f05..4092b89ac 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -71,6 +71,12 @@ + + + + + diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/60.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/60.json new file mode 100644 index 000000000..03fc70183 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/60.json @@ -0,0 +1,1325 @@ +{ + "formatVersion": 1, + "database": { + "version": 60, + "identityHash": "1f8ec0c172cc1cae16313d737f6f8e34", + "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, `locked` INTEGER NOT NULL DEFAULT 0, `hasDirectMessageBadge` INTEGER NOT NULL DEFAULT 0, `isShowHomeBoosts` INTEGER NOT NULL, `isShowHomeReplies` INTEGER NOT NULL, `isShowHomeSelfBoosts` INTEGER 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": "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 + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hasDirectMessageBadge", + "columnName": "hasDirectMessageBadge", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isShowHomeBoosts", + "columnName": "isShowHomeBoosts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeReplies", + "columnName": "isShowHomeReplies", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeSelfBoosts", + "columnName": "isShowHomeSelfBoosts", + "affinity": "INTEGER", + "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, `translationEnabled` 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 + }, + { + "fieldPath": "translationEnabled", + "columnName": "translationEnabled", + "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, `tuskyAccountId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT NOT NULL, `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 NOT NULL, `mentions` TEXT NOT NULL, `tags` TEXT NOT NULL, `application` TEXT, `poll` TEXT, `muted` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`authorServerId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) 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": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "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": true + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "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": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_tuskyAccountId", + "unique": false, + "columnNames": [ + "authorServerId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_tuskyAccountId` ON `${TABLE_NAME}` (`authorServerId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `tuskyAccountId` 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`, `tuskyAccountId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "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", + "tuskyAccountId" + ] + }, + "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": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `type` TEXT, `id` TEXT NOT NULL, `accountId` TEXT, `statusId` TEXT, `reportId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`accountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reportId`, `tuskyAccountId`) REFERENCES `NotificationReportEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reportId", + "columnName": "reportId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loading", + "columnName": "loading", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_accountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "accountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_accountId_tuskyAccountId` ON `${TABLE_NAME}` (`accountId`, `tuskyAccountId`)" + }, + { + "name": "index_NotificationEntity_statusId_tuskyAccountId", + "unique": false, + "columnNames": [ + "statusId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" + }, + { + "name": "index_NotificationEntity_reportId_tuskyAccountId", + "unique": false, + "columnNames": [ + "reportId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_reportId_tuskyAccountId` ON `${TABLE_NAME}` (`reportId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "accountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "TimelineStatusEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "statusId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "NotificationReportEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "reportId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "NotificationReportEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `category` TEXT NOT NULL, `statusIds` TEXT, `createdAt` INTEGER NOT NULL, `targetAccountId` TEXT, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`targetAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusIds", + "columnName": "statusIds", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetAccountId", + "columnName": "targetAccountId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_NotificationReportEntity_targetAccountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "targetAccountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationReportEntity_targetAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`targetAccountId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "targetAccountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "HomeTimelineEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `statusId` TEXT, `reblogAccountId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reblogAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loading", + "columnName": "loading", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_HomeTimelineEntity_statusId_tuskyAccountId", + "unique": false, + "columnNames": [ + "statusId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" + }, + { + "name": "index_HomeTimelineEntity_reblogAccountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "reblogAccountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_reblogAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`reblogAccountId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineStatusEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "statusId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "reblogAccountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + } + ], + "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, '1f8ec0c172cc1cae16313d737f6f8e34')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/62.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/62.json new file mode 100644 index 000000000..740ab36d9 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/62.json @@ -0,0 +1,1331 @@ +{ + "formatVersion": 1, + "database": { + "version": 62, + "identityHash": "f50579baaea33d99c59a34671799682a", + "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, `defaultReplyPrivacy` 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, `locked` INTEGER NOT NULL DEFAULT 0, `hasDirectMessageBadge` INTEGER NOT NULL DEFAULT 0, `isShowHomeBoosts` INTEGER NOT NULL, `isShowHomeReplies` INTEGER NOT NULL, `isShowHomeSelfBoosts` INTEGER 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": "defaultReplyPrivacy", + "columnName": "defaultReplyPrivacy", + "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 + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hasDirectMessageBadge", + "columnName": "hasDirectMessageBadge", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isShowHomeBoosts", + "columnName": "isShowHomeBoosts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeReplies", + "columnName": "isShowHomeReplies", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeSelfBoosts", + "columnName": "isShowHomeSelfBoosts", + "affinity": "INTEGER", + "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, `translationEnabled` 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 + }, + { + "fieldPath": "translationEnabled", + "columnName": "translationEnabled", + "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, `tuskyAccountId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT NOT NULL, `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 NOT NULL, `mentions` TEXT NOT NULL, `tags` TEXT NOT NULL, `application` TEXT, `poll` TEXT, `muted` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`authorServerId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) 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": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "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": true + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "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": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_tuskyAccountId", + "unique": false, + "columnNames": [ + "authorServerId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_tuskyAccountId` ON `${TABLE_NAME}` (`authorServerId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `tuskyAccountId` 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`, `tuskyAccountId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "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", + "tuskyAccountId" + ] + }, + "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": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `type` TEXT, `id` TEXT NOT NULL, `accountId` TEXT, `statusId` TEXT, `reportId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`accountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reportId`, `tuskyAccountId`) REFERENCES `NotificationReportEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reportId", + "columnName": "reportId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loading", + "columnName": "loading", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_accountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "accountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_accountId_tuskyAccountId` ON `${TABLE_NAME}` (`accountId`, `tuskyAccountId`)" + }, + { + "name": "index_NotificationEntity_statusId_tuskyAccountId", + "unique": false, + "columnNames": [ + "statusId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" + }, + { + "name": "index_NotificationEntity_reportId_tuskyAccountId", + "unique": false, + "columnNames": [ + "reportId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_reportId_tuskyAccountId` ON `${TABLE_NAME}` (`reportId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "accountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "TimelineStatusEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "statusId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "NotificationReportEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "reportId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "NotificationReportEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `category` TEXT NOT NULL, `statusIds` TEXT, `createdAt` INTEGER NOT NULL, `targetAccountId` TEXT, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`targetAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusIds", + "columnName": "statusIds", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetAccountId", + "columnName": "targetAccountId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_NotificationReportEntity_targetAccountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "targetAccountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationReportEntity_targetAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`targetAccountId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "targetAccountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "HomeTimelineEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `statusId` TEXT, `reblogAccountId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reblogAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loading", + "columnName": "loading", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_HomeTimelineEntity_statusId_tuskyAccountId", + "unique": false, + "columnNames": [ + "statusId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" + }, + { + "name": "index_HomeTimelineEntity_reblogAccountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "reblogAccountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_reblogAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`reblogAccountId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineStatusEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "statusId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "reblogAccountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + } + ], + "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, 'f50579baaea33d99c59a34671799682a')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/64.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/64.json new file mode 100644 index 000000000..5f4645e66 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/64.json @@ -0,0 +1,1338 @@ +{ + "formatVersion": 1, + "database": { + "version": 64, + "identityHash": "12c1f266e9fb1d7fea3dde12866eb338", + "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, `defaultReplyPrivacy` 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, `locked` INTEGER NOT NULL DEFAULT 0, `hasDirectMessageBadge` INTEGER NOT NULL DEFAULT 0, `isShowHomeBoosts` INTEGER NOT NULL, `isShowHomeReplies` INTEGER NOT NULL, `isShowHomeSelfBoosts` INTEGER 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": "defaultReplyPrivacy", + "columnName": "defaultReplyPrivacy", + "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 + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hasDirectMessageBadge", + "columnName": "hasDirectMessageBadge", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isShowHomeBoosts", + "columnName": "isShowHomeBoosts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeReplies", + "columnName": "isShowHomeReplies", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeSelfBoosts", + "columnName": "isShowHomeSelfBoosts", + "affinity": "INTEGER", + "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, `translationEnabled` INTEGER, `filterV2Supported` INTEGER NOT NULL DEFAULT false, 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 + }, + { + "fieldPath": "translationEnabled", + "columnName": "translationEnabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filterV2Supported", + "columnName": "filterV2Supported", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `tuskyAccountId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT NOT NULL, `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 NOT NULL, `mentions` TEXT NOT NULL, `tags` TEXT NOT NULL, `application` TEXT, `poll` TEXT, `muted` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`authorServerId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) 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": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "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": true + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "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": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_tuskyAccountId", + "unique": false, + "columnNames": [ + "authorServerId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_tuskyAccountId` ON `${TABLE_NAME}` (`authorServerId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `tuskyAccountId` 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`, `tuskyAccountId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "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", + "tuskyAccountId" + ] + }, + "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": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `type` TEXT, `id` TEXT NOT NULL, `accountId` TEXT, `statusId` TEXT, `reportId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`accountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reportId`, `tuskyAccountId`) REFERENCES `NotificationReportEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reportId", + "columnName": "reportId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loading", + "columnName": "loading", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_accountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "accountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_accountId_tuskyAccountId` ON `${TABLE_NAME}` (`accountId`, `tuskyAccountId`)" + }, + { + "name": "index_NotificationEntity_statusId_tuskyAccountId", + "unique": false, + "columnNames": [ + "statusId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" + }, + { + "name": "index_NotificationEntity_reportId_tuskyAccountId", + "unique": false, + "columnNames": [ + "reportId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_reportId_tuskyAccountId` ON `${TABLE_NAME}` (`reportId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "accountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "TimelineStatusEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "statusId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "NotificationReportEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "reportId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "NotificationReportEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `category` TEXT NOT NULL, `statusIds` TEXT, `createdAt` INTEGER NOT NULL, `targetAccountId` TEXT, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`targetAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusIds", + "columnName": "statusIds", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetAccountId", + "columnName": "targetAccountId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_NotificationReportEntity_targetAccountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "targetAccountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationReportEntity_targetAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`targetAccountId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "targetAccountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "HomeTimelineEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `statusId` TEXT, `reblogAccountId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reblogAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loading", + "columnName": "loading", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_HomeTimelineEntity_statusId_tuskyAccountId", + "unique": false, + "columnNames": [ + "statusId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" + }, + { + "name": "index_HomeTimelineEntity_reblogAccountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "reblogAccountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_reblogAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`reblogAccountId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineStatusEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "statusId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "reblogAccountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + } + ], + "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, '12c1f266e9fb1d7fea3dde12866eb338')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/66.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/66.json new file mode 100644 index 000000000..ae04564c3 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/66.json @@ -0,0 +1,1346 @@ +{ + "formatVersion": 1, + "database": { + "version": 66, + "identityHash": "a17a9b196abd59db5104b46ea19c4d10", + "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, `profileHeaderUrl` TEXT NOT NULL DEFAULT '', `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, `defaultReplyPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL DEFAULT 0, `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, `locked` INTEGER NOT NULL DEFAULT 0, `hasDirectMessageBadge` INTEGER NOT NULL DEFAULT 0, `isShowHomeBoosts` INTEGER NOT NULL, `isShowHomeReplies` INTEGER NOT NULL, `isShowHomeSelfBoosts` INTEGER 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": "profileHeaderUrl", + "columnName": "profileHeaderUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "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": "defaultReplyPrivacy", + "columnName": "defaultReplyPrivacy", + "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, + "defaultValue": "0" + }, + { + "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 + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hasDirectMessageBadge", + "columnName": "hasDirectMessageBadge", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isShowHomeBoosts", + "columnName": "isShowHomeBoosts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeReplies", + "columnName": "isShowHomeReplies", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeSelfBoosts", + "columnName": "isShowHomeSelfBoosts", + "affinity": "INTEGER", + "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, `translationEnabled` INTEGER, `filterV2Supported` INTEGER NOT NULL DEFAULT false, 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 + }, + { + "fieldPath": "translationEnabled", + "columnName": "translationEnabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filterV2Supported", + "columnName": "filterV2Supported", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `tuskyAccountId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT NOT NULL, `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 NOT NULL, `mentions` TEXT NOT NULL, `tags` TEXT NOT NULL, `application` TEXT, `poll` TEXT, `muted` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`authorServerId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) 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": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "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": true + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "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": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_tuskyAccountId", + "unique": false, + "columnNames": [ + "authorServerId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_tuskyAccountId` ON `${TABLE_NAME}` (`authorServerId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `tuskyAccountId` 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`, `tuskyAccountId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "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", + "tuskyAccountId" + ] + }, + "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": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `type` TEXT, `id` TEXT NOT NULL, `accountId` TEXT, `statusId` TEXT, `reportId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`accountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reportId`, `tuskyAccountId`) REFERENCES `NotificationReportEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reportId", + "columnName": "reportId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loading", + "columnName": "loading", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_accountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "accountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_accountId_tuskyAccountId` ON `${TABLE_NAME}` (`accountId`, `tuskyAccountId`)" + }, + { + "name": "index_NotificationEntity_statusId_tuskyAccountId", + "unique": false, + "columnNames": [ + "statusId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" + }, + { + "name": "index_NotificationEntity_reportId_tuskyAccountId", + "unique": false, + "columnNames": [ + "reportId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_reportId_tuskyAccountId` ON `${TABLE_NAME}` (`reportId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "accountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "TimelineStatusEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "statusId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "NotificationReportEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "reportId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "NotificationReportEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `category` TEXT NOT NULL, `statusIds` TEXT, `createdAt` INTEGER NOT NULL, `targetAccountId` TEXT, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`targetAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusIds", + "columnName": "statusIds", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetAccountId", + "columnName": "targetAccountId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_NotificationReportEntity_targetAccountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "targetAccountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationReportEntity_targetAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`targetAccountId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "targetAccountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "HomeTimelineEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `statusId` TEXT, `reblogAccountId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reblogAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loading", + "columnName": "loading", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_HomeTimelineEntity_statusId_tuskyAccountId", + "unique": false, + "columnNames": [ + "statusId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" + }, + { + "name": "index_HomeTimelineEntity_reblogAccountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "reblogAccountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_reblogAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`reblogAccountId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineStatusEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "statusId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "reblogAccountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + } + ], + "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, 'a17a9b196abd59db5104b46ea19c4d10')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/68.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/68.json new file mode 100644 index 000000000..d07482495 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/68.json @@ -0,0 +1,1399 @@ +{ + "formatVersion": 1, + "database": { + "version": 68, + "identityHash": "45583265bb92757d39163ee6c19dc4e5", + "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, `profileHeaderUrl` TEXT NOT NULL DEFAULT '', `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, `notificationsUpdates` INTEGER NOT NULL, `notificationsAdmin` INTEGER NOT NULL DEFAULT true, `notificationsOther` INTEGER NOT NULL DEFAULT true, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultReplyPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL DEFAULT 0, `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, `locked` INTEGER NOT NULL DEFAULT 0, `hasDirectMessageBadge` INTEGER NOT NULL DEFAULT 0, `isShowHomeBoosts` INTEGER NOT NULL, `isShowHomeReplies` INTEGER NOT NULL, `isShowHomeSelfBoosts` INTEGER 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": "profileHeaderUrl", + "columnName": "profileHeaderUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "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": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsAdmin", + "columnName": "notificationsAdmin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "notificationsOther", + "columnName": "notificationsOther", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "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": "defaultReplyPrivacy", + "columnName": "defaultReplyPrivacy", + "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, + "defaultValue": "0" + }, + { + "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 + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hasDirectMessageBadge", + "columnName": "hasDirectMessageBadge", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isShowHomeBoosts", + "columnName": "isShowHomeBoosts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeReplies", + "columnName": "isShowHomeReplies", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeSelfBoosts", + "columnName": "isShowHomeSelfBoosts", + "affinity": "INTEGER", + "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, `translationEnabled` INTEGER, `filterV2Supported` INTEGER NOT NULL DEFAULT false, 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 + }, + { + "fieldPath": "translationEnabled", + "columnName": "translationEnabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filterV2Supported", + "columnName": "filterV2Supported", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `tuskyAccountId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT NOT NULL, `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 NOT NULL, `mentions` TEXT NOT NULL, `tags` TEXT NOT NULL, `application` TEXT, `poll` TEXT, `muted` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`authorServerId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) 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": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "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": true + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "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": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_tuskyAccountId", + "unique": false, + "columnNames": [ + "authorServerId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_tuskyAccountId` ON `${TABLE_NAME}` (`authorServerId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `tuskyAccountId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `note` TEXT NOT NULL DEFAULT '', `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "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": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "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": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `type` TEXT, `id` TEXT NOT NULL, `accountId` TEXT, `statusId` TEXT, `reportId` TEXT, `event` TEXT, `moderationWarning` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`accountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reportId`, `tuskyAccountId`) REFERENCES `NotificationReportEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reportId", + "columnName": "reportId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "event", + "columnName": "event", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "moderationWarning", + "columnName": "moderationWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loading", + "columnName": "loading", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_accountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "accountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_accountId_tuskyAccountId` ON `${TABLE_NAME}` (`accountId`, `tuskyAccountId`)" + }, + { + "name": "index_NotificationEntity_statusId_tuskyAccountId", + "unique": false, + "columnNames": [ + "statusId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" + }, + { + "name": "index_NotificationEntity_reportId_tuskyAccountId", + "unique": false, + "columnNames": [ + "reportId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_reportId_tuskyAccountId` ON `${TABLE_NAME}` (`reportId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "accountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "TimelineStatusEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "statusId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "NotificationReportEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "reportId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "NotificationReportEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `category` TEXT NOT NULL, `statusIds` TEXT, `createdAt` INTEGER NOT NULL, `targetAccountId` TEXT, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`targetAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusIds", + "columnName": "statusIds", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetAccountId", + "columnName": "targetAccountId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_NotificationReportEntity_targetAccountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "targetAccountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationReportEntity_targetAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`targetAccountId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "targetAccountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "HomeTimelineEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `statusId` TEXT, `reblogAccountId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reblogAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loading", + "columnName": "loading", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_HomeTimelineEntity_statusId_tuskyAccountId", + "unique": false, + "columnNames": [ + "statusId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" + }, + { + "name": "index_HomeTimelineEntity_reblogAccountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "reblogAccountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_reblogAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`reblogAccountId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineStatusEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "statusId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "reblogAccountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "NotificationPolicyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `pendingRequestsCount` INTEGER NOT NULL, `pendingNotificationsCount` INTEGER NOT NULL, PRIMARY KEY(`tuskyAccountId`))", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingRequestsCount", + "columnName": "pendingRequestsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingNotificationsCount", + "columnName": "pendingNotificationsCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "tuskyAccountId" + ] + }, + "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, '45583265bb92757d39163ee6c19dc4e5')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/keylesspalace/tusky/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/keylesspalace/tusky/ExampleInstrumentedTest.java deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt b/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt deleted file mode 100644 index ccfc4ca62..000000000 --- a/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.keylesspalace.tusky - -import androidx.room.testing.MigrationTestHelper -import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.keylesspalace.tusky.db.AppDatabase -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -const val TEST_DB = "migration_test" - -@RunWith(AndroidJUnit4::class) -class MigrationsTest { - - @JvmField - @Rule - var helper: MigrationTestHelper = MigrationTestHelper( - InstrumentationRegistry.getInstrumentation(), - AppDatabase::class.java.canonicalName!!, - FrameworkSQLiteOpenHelperFactory() - ) - - @Test - fun migrateTo11() { - val db = helper.createDatabase(TEST_DB, 10) - - val id = 1 - val domain = "domain.site" - val token = "token" - val active = true - val accountId = "accountId" - val username = "username" - val values = arrayOf( - id, domain, token, active, accountId, username, "Display Name", - "https://picture.url", true, true, true, true, true, true, true, - true, "1000", "[]", "[{\"shortcode\": \"emoji\", \"url\": \"yes\"}]", 0, false, - false, true - ) - - db.execSQL( - "INSERT OR REPLACE INTO `AccountEntity`(`id`,`domain`,`accessToken`,`isActive`," + - "`accountId`,`username`,`displayName`,`profilePictureUrl`,`notificationsEnabled`," + - "`notificationsMentioned`,`notificationsFollowed`,`notificationsReblogged`," + - "`notificationsFavorited`,`notificationSound`,`notificationVibration`," + - "`notificationLight`,`lastNotificationId`,`activeNotifications`,`emojis`," + - "`defaultPostPrivacy`,`defaultMediaSensitivity`,`alwaysShowSensitiveMedia`," + - "`mediaPreviewEnabled`) " + - "VALUES (nullif(?, 0),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", - values - ) - - db.close() - - val newDb = helper.runMigrationsAndValidate(TEST_DB, 11, true, AppDatabase.MIGRATION_10_11) - - val cursor = newDb.query("SELECT * FROM AccountEntity") - cursor.moveToFirst() - assertEquals(id, cursor.getInt(0)) - assertEquals(domain, cursor.getString(1)) - assertEquals(token, cursor.getString(2)) - assertEquals(active, cursor.getInt(3) != 0) - assertEquals(accountId, cursor.getString(4)) - assertEquals(username, cursor.getString(5)) - } -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3b814473d..f726a20c8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,24 +19,9 @@ android:theme="@style/TuskyTheme" android:usesCleartextTraffic="false" android:localeConfig="@xml/locales_config" - android:enableOnBackInvokedCallback="true"> + android:enableOnBackInvokedCallback="true" + android:networkSecurityConfig="@xml/network_security_config"> - - - - - - - - - - - + android:exported="true" + android:alwaysRetainTaskState="true" + android:maxRecents="1" + android:theme="@style/SplashTheme"> + + + + + @@ -101,15 +93,21 @@ + + + android:alwaysRetainTaskState="true" /> @@ -117,10 +115,8 @@ android:name=".ViewMediaActivity" android:theme="@style/TuskyBaseTheme" android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize" /> - - + + @@ -155,6 +151,9 @@ + + + + val systemBarInsets = insets.getInsets(systemBars()) + scrollView.updatePadding(bottom = systemBarInsets.bottom) + + insets.inset(0, 0, 0, systemBarInsets.bottom) + } + binding.versionTextView.text = getString(R.string.about_app_version, getString(R.string.app_name), BuildConfig.VERSION_NAME) binding.deviceInfo.text = getString( @@ -90,13 +98,11 @@ class AboutActivity : BottomSheetActivity(), Injectable { } binding.copyDeviceInfo.setOnClickListener { - val text = "${binding.versionTextView.text}\n\nDevice:\n\n${binding.deviceInfo.text}\n\nAccount:\n\n${binding.accountInfo.text}" - val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Tusky version information", text) - clipboard.setPrimaryClip(clip) - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { - Toast.makeText(this, getString(R.string.about_copied), Toast.LENGTH_SHORT).show() - } + copyToClipboard( + "${binding.versionTextView.text}\n\nDevice:\n\n${binding.deviceInfo.text}\n\nAccount:\n\n${binding.accountInfo.text}", + getString(R.string.about_copied), + "Tusky version information", + ) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt index 0e88e0c7e..d92b92ab0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt @@ -16,6 +16,7 @@ package com.keylesspalace.tusky +import android.content.SharedPreferences import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -25,52 +26,48 @@ import androidx.appcompat.widget.SearchView import androidx.fragment.app.DialogFragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope -import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter import com.keylesspalace.tusky.databinding.FragmentAccountsInListBinding import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.BindingHolder -import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.unsafeLazy -import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel import com.keylesspalace.tusky.viewmodel.State +import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.launch private typealias AccountInfo = Pair -class AccountsInListFragment : DialogFragment(), Injectable { +@AndroidEntryPoint +class AccountsInListFragment : DialogFragment() { @Inject - lateinit var viewModelFactory: ViewModelFactory + lateinit var preferences: SharedPreferences - private val viewModel: AccountsInListViewModel by viewModels { viewModelFactory } - private val binding by viewBinding(FragmentAccountsInListBinding::bind) + private val viewModel: AccountsInListViewModel by viewModels() + private lateinit var binding: FragmentAccountsInListBinding private lateinit var listId: String private lateinit var listName: String - private val adapter = Adapter() - private val searchAdapter = SearchAdapter() private val radius by unsafeLazy { resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) } - private val pm by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(requireContext()) } - private val animateAvatar by unsafeLazy { pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) } - private val animateEmojis by unsafeLazy { pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) } + + private val animateAvatar by unsafeLazy { preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) } + private val animateEmojis by unsafeLazy { preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) } + private val showBotOverlay by unsafeLazy { preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle) val args = requireArguments() listId = args.getString(LIST_ID_ARG)!! listName = args.getString(LIST_NAME_ARG)!! @@ -78,6 +75,8 @@ class AccountsInListFragment : DialogFragment(), Injectable { viewModel.load(listId) } + override fun getTheme() = R.style.TuskyDialogFragment + override fun onStart() { super.onStart() dialog?.apply { @@ -89,31 +88,27 @@ class AccountsInListFragment : DialogFragment(), Injectable { } } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_accounts_in_list, container, false) - } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentAccountsInListBinding.inflate(layoutInflater) + val adapter = Adapter() + val searchAdapter = SearchAdapter() - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - binding.accountsRecycler.layoutManager = LinearLayoutManager(view.context) + binding.accountsRecycler.layoutManager = LinearLayoutManager(binding.root.context) binding.accountsRecycler.adapter = adapter - binding.accountsSearchRecycler.layoutManager = LinearLayoutManager(view.context) + binding.accountsSearchRecycler.layoutManager = LinearLayoutManager(binding.root.context) binding.accountsSearchRecycler.adapter = searchAdapter - viewLifecycleOwner.lifecycleScope.launch { + lifecycleScope.launch { viewModel.state.collect { state -> - adapter.submitList(state.accounts.asRightOrNull() ?: listOf()) + adapter.submitList(state.accounts.getOrDefault(emptyList())) - when (state.accounts) { - is Either.Right -> binding.messageView.hide() - is Either.Left -> handleError(state.accounts.value) - } + state.accounts.fold( + onSuccess = { binding.messageView.hide() }, + onFailure = { handleError(it) } + ) - setupSearchView(state) + setupSearchView(searchAdapter, state) } } @@ -132,15 +127,16 @@ class AccountsInListFragment : DialogFragment(), Injectable { return true } }) + return binding.root } - private fun setupSearchView(state: State) { + private fun setupSearchView(searchAdapter: SearchAdapter, state: State) { if (state.searchResult == null) { searchAdapter.submitList(listOf()) binding.accountsSearchRecycler.hide() binding.accountsRecycler.show() } else { - val listAccounts = state.accounts.asRightOrNull() ?: listOf() + val listAccounts = state.accounts.getOrDefault(emptyList()) val newList = state.searchResult.map { acc -> acc to listAccounts.contains(acc) } @@ -212,6 +208,7 @@ class AccountsInListFragment : DialogFragment(), Injectable { val account = getItem(position) holder.binding.displayNameTextView.text = account.name.emojify(account.emojis, holder.binding.displayNameTextView, animateEmojis) holder.binding.usernameTextView.text = account.username + holder.binding.avatarBadge.visible(showBotOverlay && account.bot) loadAvatar(account.avatar, holder.binding.avatar, radius, animateAvatar) } } @@ -263,6 +260,7 @@ class AccountsInListFragment : DialogFragment(), Injectable { holder.binding.displayNameTextView.text = account.name.emojify(account.emojis, holder.binding.displayNameTextView, animateEmojis) holder.binding.usernameTextView.text = account.username + holder.binding.avatarBadge.visible(showBotOverlay && account.bot) loadAvatar(account.avatar, holder.binding.avatar, radius, animateAvatar) holder.binding.rejectButton.apply { diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt index a5bc0b04a..e329f8da7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt @@ -1,4 +1,4 @@ -/* Copyright 2017 Andrew Dawson +/* Copyright 2024 Tusky Contributors * * This file is a part of Tusky. * @@ -12,117 +12,167 @@ * * 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; +import android.app.ActivityManager.TaskDescription +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.content.SharedPreferences +import android.graphics.BitmapFactory +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.annotation.StyleRes +import androidx.appcompat.app.AppCompatActivity +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsCompat.Type.displayCutout +import androidx.core.view.WindowInsetsCompat.Type.systemBars +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.lifecycle.ViewModelProvider.Factory +import androidx.lifecycle.lifecycleScope +import com.google.android.material.R as materialR +import com.google.android.material.color.MaterialColors +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.keylesspalace.tusky.MainActivity.Companion.redirectIntent +import com.keylesspalace.tusky.adapter.AccountSelectionAdapter +import com.keylesspalace.tusky.components.login.LoginActivity +import com.keylesspalace.tusky.components.login.LoginActivity.Companion.getIntent +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.entity.AccountEntity +import com.keylesspalace.tusky.di.PreferencesEntryPoint +import com.keylesspalace.tusky.interfaces.AccountSelectionListener +import com.keylesspalace.tusky.settings.AppTheme +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.ActivityConstants +import com.keylesspalace.tusky.util.isBlack +import com.keylesspalace.tusky.util.overrideActivityTransitionCompat +import dagger.hilt.EntryPoints +import javax.inject.Inject +import kotlinx.coroutines.launch -import android.app.ActivityManager; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.content.res.Configuration; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Color; -import android.os.Bundle; -import android.util.Log; -import android.view.MenuItem; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; -import androidx.preference.PreferenceManager; - -import com.google.android.material.color.MaterialColors; -import com.google.android.material.snackbar.Snackbar; -import com.keylesspalace.tusky.adapter.AccountSelectionAdapter; -import com.keylesspalace.tusky.components.login.LoginActivity; -import com.keylesspalace.tusky.db.AccountEntity; -import com.keylesspalace.tusky.db.AccountManager; -import com.keylesspalace.tusky.di.Injectable; -import com.keylesspalace.tusky.interfaces.AccountSelectionListener; -import com.keylesspalace.tusky.interfaces.PermissionRequester; -import com.keylesspalace.tusky.settings.AppTheme; -import com.keylesspalace.tusky.settings.PrefKeys; -import com.keylesspalace.tusky.util.ActivityExtensions; -import com.keylesspalace.tusky.util.ThemeUtils; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; - -import javax.inject.Inject; - -import static com.keylesspalace.tusky.settings.PrefKeys.APP_THEME; -import static com.keylesspalace.tusky.util.ActivityExtensions.supportsOverridingActivityTransitions; - -public abstract class BaseActivity extends AppCompatActivity implements Injectable { - - public static final String OPEN_WITH_SLIDE_IN = "OPEN_WITH_SLIDE_IN"; - - private static final String TAG = "BaseActivity"; +/** + * All activities inheriting from BaseActivity must be annotated with @AndroidEntryPoint + */ +abstract class BaseActivity : AppCompatActivity() { + @Inject + lateinit var accountManager: AccountManager @Inject - @NonNull - public AccountManager accountManager; + lateinit var preferences: SharedPreferences - private static final int REQUESTER_NONE = Integer.MAX_VALUE; - private HashMap requesters; + /** + * Allows overriding the default ViewModelProvider.Factory for testing purposes. + */ + var viewModelProviderFactory: Factory? = null - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) - if (supportsOverridingActivityTransitions() && activityTransitionWasRequested()) { - overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, R.anim.activity_open_enter, R.anim.activity_open_exit); - overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, R.anim.activity_close_enter, R.anim.activity_close_exit); + if (activityTransitionWasRequested()) { + overrideActivityTransitionCompat( + ActivityConstants.OVERRIDE_TRANSITION_OPEN, + R.anim.activity_open_enter, + R.anim.activity_open_exit + ) + overrideActivityTransitionCompat( + ActivityConstants.OVERRIDE_TRANSITION_CLOSE, + R.anim.activity_close_enter, + R.anim.activity_close_exit + ) } - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); - /* There isn't presently a way to globally change the theme of a whole application at * runtime, just individual activities. So, each activity has to set its theme before any * views are created. */ - String theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.getValue()); - Log.d("activeTheme", theme); - if (ThemeUtils.isBlack(getResources().getConfiguration(), theme)) { - setTheme(R.style.TuskyBlackTheme); + val theme = preferences.getString(PrefKeys.APP_THEME, AppTheme.DEFAULT.value) + if (isBlack(resources.configuration, theme)) { + setTheme(R.style.TuskyBlackTheme) + } else if (this is MainActivity) { + // Replace the SplashTheme of MainActivity + setTheme(R.style.TuskyTheme) } - /* set the taskdescription programmatically, the theme would turn it blue */ - String appName = getString(R.string.app_name); - Bitmap appIcon = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher); - int recentsBackgroundColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, Color.BLACK); + /* Set the taskdescription programmatically - by default the primary color is used. + * On newer Android versions (or launchers?) this doesn't seem to have an effect. */ + val appName = getString(R.string.app_name) + val recentsBackgroundColor = MaterialColors.getColor( + this, + materialR.attr.colorSurface, + Color.BLACK + ) - setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor)); - - int style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium")); - getTheme().applyStyle(style, true); - - if(requiresLogin()) { - redirectIfNotLoggedIn(); + val taskDescription = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + TaskDescription.Builder() + .setLabel(appName) + .setIcon(R.mipmap.ic_launcher) + .setPrimaryColor(recentsBackgroundColor) + .build() + } else { + val appIcon = BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher) + @Suppress("DEPRECATION") + TaskDescription(appName, appIcon, recentsBackgroundColor) } - requesters = new HashMap<>(); + setTaskDescription(taskDescription) + + val style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium")) + getTheme().applyStyle(style, true) + + if (requiresLogin()) { + redirectIfNotLoggedIn() + } } - private boolean activityTransitionWasRequested() { - return getIntent().getBooleanExtra(OPEN_WITH_SLIDE_IN, false); + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + + // currently only ComposeActivity on tablets is floating + if (!window.isFloating) { + window.decorView.setBackgroundColor(Color.BLACK) + + val contentView: View = findViewById(android.R.id.content) + contentView.setBackgroundColor(MaterialColors.getColor(contentView, android.R.attr.colorBackground)) + + // handle left/right insets. This is relevant for edge-to-edge mode in landscape orientation + ViewCompat.setOnApplyWindowInsetsListener(contentView) { _, insets -> + val systemBarInsets = insets.getInsets(systemBars()) + val displayCutoutInsets = insets.getInsets(displayCutout()) + // use padding for system bar insets so they get our background color and margin for cutout insets to turn them black + contentView.updatePadding(left = systemBarInsets.left, right = systemBarInsets.right) + contentView.updateLayoutParams { + leftMargin = displayCutoutInsets.left + rightMargin = displayCutoutInsets.right + } + + WindowInsetsCompat.Builder(insets) + .setInsets(systemBars(), Insets.of(0, systemBarInsets.top, 0, systemBarInsets.bottom)) + .setInsets(displayCutout(), Insets.of(0, displayCutoutInsets.top, 0, displayCutoutInsets.bottom)) + .build() + } + } } - @Override - protected void attachBaseContext(Context newBase) { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(newBase); + private fun activityTransitionWasRequested(): Boolean { + return intent.getBooleanExtra(OPEN_WITH_SLIDE_IN, false) + } + + override fun attachBaseContext(newBase: Context) { + // injected preferences not yet available at this point of the lifecycle + val preferences = + EntryPoints.get(newBase.applicationContext, PreferencesEntryPoint::class.java) + .preferences() // Scale text in the UI from PrefKeys.UI_TEXT_SCALE_RATIO - float uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100F); + val uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100f) - Configuration configuration = newBase.getResources().getConfiguration(); + val configuration = newBase.resources.configuration // Adjust `fontScale` in the configuration. // @@ -134,7 +184,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab // changes to the base context. It does contain contain any changes to the font scale from // "Settings > Display > Font size" in the device settings, so scaling performed here // is in addition to any scaling in the device settings. - Configuration appConfiguration = newBase.getApplicationContext().getResources().getConfiguration(); + val appConfiguration = newBase.applicationContext.resources.configuration // This only adjusts the fonts, anything measured in `dp` is unaffected by this. // You can try to adjust `densityDpi` as shown in the commented out code below. This @@ -146,163 +196,116 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab // // val displayMetrics = appContext.resources.displayMetrics // configuration.densityDpi = ((displayMetrics.densityDpi * uiScaleRatio).toInt()) - configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100F; + configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100f - Context fontScaleContext = newBase.createConfigurationContext(configuration); + val fontScaleContext = newBase.createConfigurationContext(configuration) - super.attachBaseContext(fontScaleContext); + super.attachBaseContext(fontScaleContext) } - protected boolean requiresLogin() { - return true; - } + override val defaultViewModelProviderFactory: Factory + get() = viewModelProviderFactory ?: super.defaultViewModelProviderFactory - private static int textStyle(String name) { - int style; - switch (name) { - case "smallest": - style = R.style.TextSizeSmallest; - break; - case "small": - style = R.style.TextSizeSmall; - break; - case "medium": - default: - style = R.style.TextSizeMedium; - break; - case "large": - style = R.style.TextSizeLarge; - break; - case "largest": - style = R.style.TextSizeLargest; - break; + protected open fun requiresLogin(): Boolean = true + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + onBackPressedDispatcher.onBackPressed() + return true } - return style; + return super.onOptionsItemSelected(item) } - @Override - public boolean onOptionsItemSelected(@NonNull MenuItem item) { - if (item.getItemId() == android.R.id.home) { - getOnBackPressedDispatcher().onBackPressed(); - return true; - } - return super.onOptionsItemSelected(item); - } + private fun redirectIfNotLoggedIn() { + val currentAccounts = accountManager.accounts - @Override - public void finish() { - super.finish(); - // if this activity was opened with slide-in, close it with slide out - if (!supportsOverridingActivityTransitions() && activityTransitionWasRequested()) { - overridePendingTransition(R.anim.activity_close_enter, R.anim.activity_close_exit); + if (currentAccounts.isEmpty()) { + val intent = getIntent(this@BaseActivity, LoginActivity.MODE_DEFAULT) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + finish() } } - protected void redirectIfNotLoggedIn() { - AccountEntity account = accountManager.getActiveAccount(); - if (account == null) { - Intent intent = new Intent(this, LoginActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); - ActivityExtensions.startActivityWithSlideInAnimation(this, intent); - finish(); - } - } + fun showAccountChooserDialog( + dialogTitle: CharSequence?, + showActiveAccount: Boolean, + listener: AccountSelectionListener + ) { + val accounts = accountManager.accounts.toMutableList() + val activeAccount = accountManager.activeAccount - protected void showErrorDialog(@Nullable View anyView, @StringRes int descriptionId, @StringRes int actionId, @Nullable View.OnClickListener listener) { - if (anyView != null) { - Snackbar bar = Snackbar.make(anyView, getString(descriptionId), Snackbar.LENGTH_SHORT); - bar.setAction(actionId, listener); - bar.show(); - } - } - - public void showAccountChooserDialog(@Nullable CharSequence dialogTitle, boolean showActiveAccount, @NonNull AccountSelectionListener listener) { - List accounts = accountManager.getAllAccountsOrderedByActive(); - AccountEntity activeAccount = accountManager.getActiveAccount(); - - switch(accounts.size()) { - case 1: - listener.onAccountSelected(activeAccount); - return; - case 2: - if (!showActiveAccount) { - for (AccountEntity account : accounts) { - if (activeAccount != account) { - listener.onAccountSelected(account); - return; - } + when (accounts.size) { + 1 -> { + listener.onAccountSelected(activeAccount!!) + return + } + 2 -> if (!showActiveAccount) { + for (account in accounts) { + if (activeAccount !== account) { + listener.onAccountSelected(account) + return } } - break; - } - - if (!showActiveAccount && activeAccount != null) { - accounts.remove(activeAccount); - } - AccountSelectionAdapter adapter = new AccountSelectionAdapter(this); - adapter.addAll(accounts); - - new AlertDialog.Builder(this) - .setTitle(dialogTitle) - .setAdapter(adapter, (dialogInterface, index) -> listener.onAccountSelected(accounts.get(index))) - .show(); - } - - public @Nullable String getOpenAsText() { - List accounts = accountManager.getAllAccountsOrderedByActive(); - switch (accounts.size()) { - case 0: - case 1: - return null; - case 2: - for (AccountEntity account : accounts) { - if (account != accountManager.getActiveAccount()) { - return String.format(getString(R.string.action_open_as), account.getFullName()); - } - } - return null; - default: - return String.format(getString(R.string.action_open_as), "…"); - } - } - - public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) { - accountManager.setActiveAccount(account.getId()); - Intent intent = MainActivity.redirectIntent(this, account.getId(), url); - - startActivity(intent); - finish(); - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - if (requesters.containsKey(requestCode)) { - PermissionRequester requester = requesters.remove(requestCode); - requester.onRequestPermissionsResult(permissions, grantResults); - } - } - - public void requestPermissions(@NonNull String[] permissions, @NonNull PermissionRequester requester) { - ArrayList permissionsToRequest = new ArrayList<>(); - for(String permission: permissions) { - if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { - permissionsToRequest.add(permission); } } - if (permissionsToRequest.isEmpty()) { - int[] permissionsAlreadyGranted = new int[permissions.length]; - requester.onRequestPermissionsResult(permissions, permissionsAlreadyGranted); - return; + if (!showActiveAccount && activeAccount != null) { + accounts.remove(activeAccount) + } + val adapter = AccountSelectionAdapter( + this, + preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), + preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + ) + adapter.addAll(accounts) + + MaterialAlertDialogBuilder(this) + .setTitle(dialogTitle) + .setAdapter(adapter) { _: DialogInterface?, index: Int -> + listener.onAccountSelected(accounts[index]) + } + .show() + } + + val openAsText: String? + get() { + val accounts = accountManager.accounts + when (accounts.size) { + 0, 1 -> return null + 2 -> { + for (account in accounts) { + if (account !== accountManager.activeAccount) { + return getString(R.string.action_open_as, account.fullName) + } + } + return null + } + + else -> return getString(R.string.action_open_as, "…") + } } - int newKey = requester == null ? REQUESTER_NONE : requesters.size(); - if (newKey != REQUESTER_NONE) { - requesters.put(newKey, requester); - } - String[] permissionsCopy = new String[permissionsToRequest.size()]; - permissionsToRequest.toArray(permissionsCopy); - ActivityCompat.requestPermissions(this, permissionsCopy, newKey); + fun openAsAccount(url: String, account: AccountEntity) { + lifecycleScope.launch { + accountManager.setActiveAccount(account.id) + val intent = redirectIntent(this@BaseActivity, account.id, url) + startActivity(intent) + finish() + } + } + + companion object { + const val OPEN_WITH_SLIDE_IN = "OPEN_WITH_SLIDE_IN" + + @StyleRes + private fun textStyle(name: String?): Int = when (name) { + "smallest" -> R.style.TextSizeSmallest + "small" -> R.style.TextSizeSmall + "medium" -> R.style.TextSizeMedium + "large" -> R.style.TextSizeLarge + "largest" -> R.style.TextSizeLargest + else -> R.style.TextSizeMedium + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt index 73c87cf2e..82023881b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -22,6 +22,10 @@ import android.view.View import android.widget.LinearLayout import android.widget.Toast import androidx.annotation.VisibleForTesting +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat.Type.ime +import androidx.core.view.WindowInsetsCompat.Type.systemBars +import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import at.connyduck.calladapter.networkresult.fold import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -62,6 +66,14 @@ abstract class BottomSheetActivity : BaseActivity() { override fun onSlide(bottomSheet: View, slideOffset: Float) {} }) + + ViewCompat.setOnApplyWindowInsetsListener(bottomSheetLayout) { _, insets -> + val systemBarsInsets = insets.getInsets(systemBars() or ime()) + val bottomInsets = systemBarsInsets.bottom + + bottomSheetLayout.updatePadding(bottom = bottomInsets) + insets + } } open fun viewUrl( diff --git a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt index e0f6a3bc8..87eea1340 100644 --- a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt @@ -17,7 +17,6 @@ package com.keylesspalace.tusky import android.content.Intent import android.graphics.Bitmap -import android.graphics.Color import android.net.Uri import android.os.Bundle import android.util.Log @@ -28,7 +27,13 @@ import android.widget.ImageView import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsCompat.Type.ime +import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.isVisible +import androidx.core.view.updatePadding import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager @@ -39,12 +44,13 @@ 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.R as materialR +import com.google.android.material.color.MaterialColors +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.databinding.ActivityEditProfileBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Success @@ -57,11 +63,12 @@ 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 +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -class EditProfileActivity : BaseActivity(), Injectable { +@AndroidEntryPoint +class EditProfileActivity : BaseActivity() { companion object { const val AVATAR_SIZE = 400 @@ -69,10 +76,7 @@ class EditProfileActivity : BaseActivity(), Injectable { const val HEADER_HEIGHT = 500 } - @Inject - lateinit var viewModelFactory: ViewModelFactory - - private val viewModel: EditProfileViewModel by viewModels { viewModelFactory } + private val viewModel: EditProfileViewModel by viewModels() private val binding by viewBinding(ActivityEditProfileBinding::inflate) @@ -121,6 +125,25 @@ class EditProfileActivity : BaseActivity(), Injectable { setDisplayShowHomeEnabled(true) } + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { scrollView, insets -> + // if keyboard visible -> set inset on the root to push the scrollview up + // if keyboard hidden -> set inset on the scrollview so last element does not get obscured by navigation bar + // scrollview has clipToPadding set to false so it draws behind the navigation bar in edge-to-edge mode + val imeInsets = insets.getInsets(ime()) + val systemBarsInsets = insets.getInsets(systemBars()) + binding.root.updatePadding(bottom = imeInsets.bottom) + val scrollViewPadding = if (imeInsets.bottom == 0) { + systemBarsInsets.bottom + } else { + 0 + } + binding.scrollView.updatePadding(bottom = scrollViewPadding) + WindowInsetsCompat.Builder(insets) + .setInsets(ime(), Insets.of(imeInsets.left, imeInsets.top, imeInsets.right, 0)) + .setInsets(systemBars(), Insets.of(systemBarsInsets.left, systemBarsInsets.top, imeInsets.right, 0)) + .build() + } + binding.avatarButton.setOnClickListener { pickMedia(PickType.AVATAR) } binding.headerButton.setOnClickListener { pickMedia(PickType.HEADER) } @@ -129,7 +152,7 @@ class EditProfileActivity : BaseActivity(), Injectable { val plusDrawable = IconicsDrawable(this, GoogleMaterial.Icon.gmd_add).apply { sizeDp = 12 - colorInt = Color.WHITE + colorInt = MaterialColors.getColor(binding.addFieldButton, materialR.attr.colorOnPrimary) } binding.addFieldButton.setCompoundDrawablesRelativeWithIntrinsicBounds( @@ -369,7 +392,7 @@ class EditProfileActivity : BaseActivity(), Injectable { } } - private suspend fun launchSaveDialog() = AlertDialog.Builder(this) + private suspend fun launchSaveDialog() = MaterialAlertDialogBuilder(this) .setMessage(getString(R.string.dialog_save_profile_changes_message)) .create() .await(R.string.action_save, R.string.action_discard) diff --git a/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt b/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt index 02e9b1a51..e4cd82b3b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt @@ -19,8 +19,12 @@ import android.os.Bundle import android.util.Log import android.widget.TextView import androidx.annotation.RawRes +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat.Type.systemBars +import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import com.keylesspalace.tusky.databinding.ActivityLicenseBinding +import dagger.hilt.android.AndroidEntryPoint import java.io.IOException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -28,6 +32,7 @@ import kotlinx.coroutines.withContext import okio.buffer import okio.source +@AndroidEntryPoint class LicenseActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -43,6 +48,13 @@ class LicenseActivity : BaseActivity() { setTitle(R.string.title_licenses) + ViewCompat.setOnApplyWindowInsetsListener(binding.scrollView) { scrollView, insets -> + val systemBarInsets = insets.getInsets(systemBars()) + scrollView.updatePadding(bottom = systemBarInsets.bottom) + + insets.inset(0, 0, 0, systemBarInsets.bottom) + } + loadFileIntoTextView(R.raw.apache, binding.licenseApacheTextView) } diff --git a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt index c5c7956b4..480e32327 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt @@ -23,24 +23,25 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.WindowManager import android.widget.PopupMenu 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 import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.databinding.ActivityListsBinding import com.keylesspalace.tusky.databinding.DialogListBinding import com.keylesspalace.tusky.databinding.ItemListBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.ensureBottomMargin +import com.keylesspalace.tusky.util.ensureBottomPadding import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation @@ -53,22 +54,15 @@ import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.ERROR_OTHER import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.INITIAL import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.LOADED import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.LOADING -import dagger.android.DispatchingAndroidInjector -import dagger.android.HasAndroidInjector -import javax.inject.Inject +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch // TODO use the ListSelectionFragment (and/or its adapter or binding) here; but keep the LoadingState from here (?) -class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { +@AndroidEntryPoint +class ListsActivity : BaseActivity() { - @Inject - lateinit var viewModelFactory: ViewModelFactory - - @Inject - lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector - - private val viewModel: ListsViewModel by viewModels { viewModelFactory } + private val viewModel: ListsViewModel by viewModels() private val binding by viewBinding(ActivityListsBinding::inflate) @@ -86,6 +80,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { setDisplayShowHomeEnabled(true) } + binding.addListButton.ensureBottomMargin() + binding.listsRecycler.ensureBottomPadding(fab = true) + binding.listsRecycler.adapter = adapter binding.listsRecycler.layoutManager = LinearLayoutManager(this) binding.listsRecycler.addItemDecoration( @@ -93,7 +90,6 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { ) binding.swipeRefreshLayout.setOnRefreshListener { viewModel.retryLoading() } - binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) lifecycleScope.launch { viewModel.state.collect(this@ListsActivity::update) @@ -117,11 +113,23 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { } private fun showlistNameDialog(list: MastoList?) { + var selectedReplyPolicyIndex = MastoList.ReplyPolicy.from(list?.repliesPolicy).ordinal + + val replyPolicies = resources.getStringArray(R.array.list_reply_policies_display) val binding = DialogListBinding.inflate(layoutInflater).apply { - replyPolicySpinner.setSelection(MastoList.ReplyPolicy.from(list?.repliesPolicy).ordinal) + replyPolicyDropDown.setText(replyPolicies[selectedReplyPolicyIndex]) + replyPolicyDropDown.setSimpleItems(replyPolicies) + replyPolicyDropDown.setOnItemClickListener { _, _, position, _ -> + selectedReplyPolicyIndex = position + } } - val dialog = AlertDialog.Builder(this) + val inset = resources.getDimensionPixelSize(R.dimen.dialog_inset) + val dialog = MaterialAlertDialogBuilder(this) .setView(binding.root) + .setBackgroundInsetTop(inset) + .setBackgroundInsetEnd(inset) + .setBackgroundInsetBottom(inset) + .setBackgroundInsetStart(inset) .setPositiveButton( if (list == null) { R.string.action_create_list @@ -133,17 +141,23 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { binding.nameText.text.toString(), list?.id, binding.exclusiveCheckbox.isChecked, - MastoList.ReplyPolicy.entries[binding.replyPolicySpinner.selectedItemPosition].policy + MastoList.ReplyPolicy.entries[selectedReplyPolicyIndex].policy ) } .setNegativeButton(android.R.string.cancel, null) .show() + // yes, SOFT_INPUT_ADJUST_RESIZE is deprecated, but without it the dropdown can get behind the keyboard + dialog.window?.setSoftInputMode( + WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE or WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE + ) + binding.nameText.let { editText -> editText.doOnTextChanged { s, _, _, _ -> dialog.getButton(Dialog.BUTTON_POSITIVE).isEnabled = s?.isNotBlank() == true } editText.setText(list?.title) + editText.requestFocus() editText.text?.let { editText.setSelection(it.length) } } @@ -157,7 +171,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { } private fun showListDeleteDialog(list: MastoList) { - AlertDialog.Builder(this) + MaterialAlertDialogBuilder(this) .setMessage(getString(R.string.dialog_delete_list_warning, list.title)) .setPositiveButton(R.string.action_delete) { _, _ -> viewModel.deleteList(list.id) @@ -287,8 +301,6 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { } } - override fun androidInjector() = dispatchingAndroidInjector - companion object { fun newIntent(context: Context) = Intent(context, ListsActivity::class.java) } diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 3adb29f2a..0c72251fd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -38,41 +38,40 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.MenuItem.SHOW_AS_ACTION_NEVER import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams import android.widget.ImageView import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.appcompat.content.res.AppCompatResources import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat -import androidx.core.content.IntentCompat import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.view.GravityCompat +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.MenuProvider +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.forEach import androidx.core.view.isVisible -import androidx.drawerlayout.widget.DrawerLayout +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope -import androidx.preference.PreferenceManager import androidx.viewpager2.widget.MarginPageTransformer -import at.connyduck.calladapter.networkresult.fold import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.FixedSizeDrawable import com.bumptech.glide.request.transition.Transition +import com.google.android.material.R as materialR import com.google.android.material.color.MaterialColors +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.tabs.TabLayout 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.ConversationsLoadingEvent import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.MainTabsChangedEvent -import com.keylesspalace.tusky.appstore.NewNotificationsEvent -import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent -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 @@ -80,39 +79,30 @@ 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.disableAllNotifications -import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback -import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity import com.keylesspalace.tusky.components.search.SearchActivity +import com.keylesspalace.tusky.components.systemnotifications.NotificationService 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.ApplicationScope -import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.entity.Status 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.pager.MainPagerAdapter import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.DeveloperToolsUseCase import com.keylesspalace.tusky.usecase.LogoutUsecase -import com.keylesspalace.tusky.util.ShareShortcutHelper -import com.keylesspalace.tusky.util.deleteStaleCachedMedia +import com.keylesspalace.tusky.util.ActivityConstants import com.keylesspalace.tusky.util.emojify -import com.keylesspalace.tusky.util.getDimension +import com.keylesspalace.tusky.util.getParcelableExtraCompat import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.overrideActivityTransitionCompat import com.keylesspalace.tusky.util.reduceSwipeSensitivity import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation -import com.keylesspalace.tusky.util.supportsOverridingActivityTransitions -import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial @@ -141,21 +131,22 @@ import com.mikepenz.materialdrawer.util.addItems import com.mikepenz.materialdrawer.util.addItemsAtPosition import com.mikepenz.materialdrawer.util.updateBadge import com.mikepenz.materialdrawer.widget.AccountHeaderView -import dagger.android.DispatchingAndroidInjector -import dagger.android.HasAndroidInjector +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.migration.OptionalInject import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, MenuProvider { - @Inject - lateinit var androidInjector: DispatchingAndroidInjector +@OptionalInject +@AndroidEntryPoint +class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { @Inject lateinit var eventHub: EventHub + @Inject + lateinit var notificationService: NotificationService + @Inject lateinit var cacheUpdater: CacheUpdater @@ -168,23 +159,16 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje @Inject lateinit var developerToolsUseCase: DeveloperToolsUseCase - @Inject - lateinit var shareShortcutHelper: ShareShortcutHelper - - @Inject - @ApplicationScope - lateinit var externalScope: CoroutineScope + private val viewModel: MainViewModel by viewModels() private val binding by viewBinding(ActivityMainBinding::inflate) + private lateinit var activeAccount: AccountEntity + private lateinit var header: AccountHeaderView private var onTabSelectedListener: OnTabSelectedListener? = null - private var unreadAnnouncementsCount = 0 - - private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) } - // We need to know if the emoji pack has been changed private var selectedEmojiPack: String? = null @@ -198,104 +182,114 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private val onBackPressedCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { - when { - binding.mainDrawerLayout.isOpen -> { - binding.mainDrawerLayout.close() - } - binding.viewPager.currentItem != 0 -> { - binding.viewPager.currentItem = 0 - } - } + binding.viewPager.currentItem = 0 } } + private val requestNotificationPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) { + viewModel.setupNotifications() + } + } + @SuppressLint("RestrictedApi") override fun onCreate(savedInstanceState: Bundle?) { + // Newer Android versions don't need to install the compat Splash Screen + // and it can cause theming bugs. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + installSplashScreen() + } super.onCreate(savedInstanceState) - val activeAccount = accountManager.activeAccount - ?: return // will be redirected to LoginActivity by BaseActivity - - if (supportsOverridingActivityTransitions() && explodeAnimationWasRequested()) { - overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, R.anim.explode, R.anim.activity_open_exit) + // make sure MainActivity doesn't hide other activities when launcher icon is clicked again + if (!isTaskRoot && + intent.hasCategory(Intent.CATEGORY_LAUNCHER) && + intent.action == Intent.ACTION_MAIN + ) { + finish() + return } + // will be redirected to LoginActivity by BaseActivity + activeAccount = accountManager.activeAccount ?: return + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + requestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + + if (explodeAnimationWasRequested()) { + overrideActivityTransitionCompat( + ActivityConstants.OVERRIDE_TRANSITION_OPEN, + R.anim.explode, + R.anim.activity_open_exit + ) + } + + selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "") + var showNotificationTab = false // check for savedInstanceState in order to not handle intent events more than once if (intent != null && savedInstanceState == null) { - val notificationId = intent.getIntExtra(NOTIFICATION_ID, -1) - if (notificationId != -1) { - // opened from a notification action, cancel the notification - val notificationManager = getSystemService( - NOTIFICATION_SERVICE - ) as NotificationManager - notificationManager.cancel(intent.getStringExtra(NOTIFICATION_TAG), notificationId) - } - - /** there are two possibilities the accountId can be passed to MainActivity: - * - from our code as Long Intent Extra TUSKY_ACCOUNT_ID - * - from share shortcuts as String 'android.intent.extra.shortcut.ID' - */ - var tuskyAccountId = intent.getLongExtra(TUSKY_ACCOUNT_ID, -1) - if (tuskyAccountId == -1L) { - val accountIdString = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) - if (accountIdString != null) { - tuskyAccountId = accountIdString.toLong() - } - } - val accountRequested = tuskyAccountId != -1L - if (accountRequested && tuskyAccountId != activeAccount.id) { - accountManager.setActiveAccount(tuskyAccountId) - } - - val openDrafts = intent.getBooleanExtra(OPEN_DRAFTS, false) - - if (canHandleMimeType(intent.type) || intent.hasExtra(COMPOSE_OPTIONS)) { - // Sharing to Tusky from an external app - if (accountRequested) { - // The correct account is already active - forwardToComposeActivity(intent) - } else { - // No account was provided, show the chooser - showAccountChooserDialog( - getString(R.string.action_share_as), - true, - object : AccountSelectionListener { - override fun onAccountSelected(account: AccountEntity) { - val requestedId = account.id - if (requestedId == activeAccount.id) { - // The correct account is already active - forwardToComposeActivity(intent) - } else { - // A different account was requested, restart the activity - intent.putExtra(TUSKY_ACCOUNT_ID, requestedId) - changeAccount(requestedId, intent) - } - } - } - ) - } - } else if (openDrafts) { - val intent = DraftsActivity.newIntent(this) - startActivity(intent) - } else if (accountRequested && intent.hasExtra(NOTIFICATION_TYPE)) { - // user clicked a notification, show follow requests for type FOLLOW_REQUEST, - // otherwise show notification tab - if (intent.getStringExtra(NOTIFICATION_TYPE) == Notification.Type.FOLLOW_REQUEST.name) { - val intent = AccountListActivity.newIntent( - this, - AccountListActivity.Type.FOLLOW_REQUESTS - ) - startActivityWithSlideInAnimation(intent) - } else { - showNotificationTab = true - } + showNotificationTab = handleIntent(intent, activeAccount) + if (isFinishing) { + // handleIntent() finished this activity and started a new one - no need to continue initialization + return } } - window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own + setContentView(binding.root) + val bottomBarHeight = if (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom") { + resources.getDimensionPixelSize(R.dimen.bottomAppBarHeight) + } else { + 0 + } + + val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + ViewCompat.setOnApplyWindowInsetsListener(binding.viewPager) { _, insets -> + val systemBarsInsets = insets.getInsets(systemBars()) + val bottomInsets = systemBarsInsets.bottom + + binding.composeButton.updateLayoutParams { + bottomMargin = bottomBarHeight + fabMargin + bottomInsets + } + binding.mainDrawer.recyclerView.updatePadding(bottom = bottomInsets) + + if (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "top") { + insets + } else { + binding.viewPager.updatePadding(bottom = bottomBarHeight + bottomInsets) + + /* BottomAppBar could handle size and insets automatically, but then it gets quite large, + so we do it like this instead */ + binding.bottomNav.updateLayoutParams { + height = bottomBarHeight + bottomInsets + } + binding.bottomNav.updatePadding(bottom = bottomInsets) + binding.bottomTabLayout.updateLayoutParams { + bottomMargin = bottomInsets + } + insets.inset(0, 0, 0, bottomInsets) + } + } + } else { + // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own + // on Vanilla Ice Cream (API 35) and up there is no status bar color because of edge-to-edge mode + @Suppress("DEPRECATION") + window.statusBarColor = Color.TRANSPARENT + + binding.composeButton.updateLayoutParams { + bottomMargin = bottomBarHeight + fabMargin + } + binding.viewPager.updatePadding(bottom = bottomBarHeight) + } + binding.composeButton.setOnClickListener { val composeIntent = Intent(applicationContext, ComposeActivity::class.java) startActivity(composeIntent) @@ -309,16 +303,17 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje "top" -> setSupportActionBar(binding.topNav) "bottom" -> setSupportActionBar(binding.bottomNav) } - binding.mainToolbar.hide() + // this is a bit hacky, but when the mainToolbar is GONE, the toolbar size gets messed up for some reason + binding.mainToolbar.layoutParams.height = 0 + binding.mainToolbar.visibility = View.INVISIBLE // There's not enough space in the top/bottom bars to show the title as well. supportActionBar?.setDisplayShowTitleEnabled(false) } else { setSupportActionBar(binding.mainToolbar) + binding.mainToolbar.layoutParams.height = LayoutParams.WRAP_CONTENT binding.mainToolbar.show() } - loadDrawerAvatar(activeAccount.profilePictureUrl, true) - addMenuProvider(this) binding.viewPager.reduceSwipeSensitivity() @@ -326,112 +321,146 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje setupDrawer( savedInstanceState, addSearchButton = hideTopToolbar, - addTrendingTagsButton = !accountManager.activeAccount!!.tabPreferences.hasTab( + addTrendingTagsButton = !activeAccount.tabPreferences.hasTab( TRENDING_TAGS ), - addTrendingStatusesButton = !accountManager.activeAccount!!.tabPreferences.hasTab( + addTrendingStatusesButton = !activeAccount.tabPreferences.hasTab( TRENDING_STATUSES ) ) - /* 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. */ - fetchUserInfo() + lifecycleScope.launch { + viewModel.accounts.collect(::updateProfiles) + } - fetchAnnouncements() + lifecycleScope.launch { + viewModel.unreadAnnouncementsCount.collect(::updateAnnouncementsBadge) + } // 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) + tabAdapter = MainPagerAdapter(emptyList(), this@MainActivity) binding.viewPager.adapter = tabAdapter - setupTabs(showNotificationTab) + lifecycleScope.launch { + viewModel.tabs.collect(::setupTabs) + } + if (showNotificationTab) { + val position = viewModel.tabs.value.indexOfFirst { it.id == NOTIFICATIONS } + if (position != -1) { + binding.viewPager.setCurrentItem(position, false) + } + } lifecycleScope.launch { - eventHub.events.collect { event -> - when (event) { - is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData) - is MainTabsChangedEvent -> { - refreshMainDrawerItems( - addSearchButton = hideTopToolbar, - addTrendingTagsButton = !event.newTabs.hasTab(TRENDING_TAGS), - addTrendingStatusesButton = !event.newTabs.hasTab(TRENDING_STATUSES) - ) + viewModel.showDirectMessagesBadge.collect { showBadge -> + updateDirectMessageBadge(showBadge) + } + } - setupTabs(false) - } - is AnnouncementReadEvent -> { - unreadAnnouncementsCount-- - updateAnnouncementsBadge() - } - is NewNotificationsEvent -> { - directMessageTab?.let { - if (event.accountId == activeAccount.accountId) { - val hasDirectMessageNotification = - event.notifications.any { - it.type == Notification.Type.MENTION && it.status?.visibility == Status.Visibility.DIRECT - } + onBackPressedDispatcher.addCallback(this@MainActivity, onBackPressedCallback) - if (hasDirectMessageNotification) { - showDirectMessageBadge(true) - } + // "Post failed" dialog should display in this activity + draftsAlert.observeInContext(this@MainActivity, true) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + val showNotificationTab = handleIntent(intent, activeAccount) + if (showNotificationTab) { + val tabs = activeAccount.tabPreferences + val position = tabs.indexOfFirst { it.id == NOTIFICATIONS } + if (position != -1) { + binding.viewPager.setCurrentItem(position, false) + } + } + } + + override fun onDestroy() { + cacheUpdater.stop() + super.onDestroy() + } + + /** Handle an incoming Intent, + * @returns true when the intent is coming from an notification and the interface should show the notification tab. + */ + private fun handleIntent(intent: Intent, activeAccount: AccountEntity): Boolean { + val notificationId = intent.getIntExtra(NOTIFICATION_ID, -1) + if (notificationId != -1) { + // opened from a notification action, cancel the notification + val notificationManager = getSystemService( + NOTIFICATION_SERVICE + ) as NotificationManager + notificationManager.cancel(intent.getStringExtra(NOTIFICATION_TAG), notificationId) + } + + /** there are two possibilities the accountId can be passed to MainActivity: + * - from our code as Long Intent Extra TUSKY_ACCOUNT_ID + * - from share shortcuts as String 'android.intent.extra.shortcut.ID' + */ + var tuskyAccountId = intent.getLongExtra(TUSKY_ACCOUNT_ID, -1) + if (tuskyAccountId == -1L) { + val accountIdString = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) + if (accountIdString != null) { + tuskyAccountId = accountIdString.toLong() + } + } + val accountRequested = tuskyAccountId != -1L + if (accountRequested && tuskyAccountId != activeAccount.id) { + changeAccount(tuskyAccountId, intent) + return false + } + + val openDrafts = intent.getBooleanExtra(OPEN_DRAFTS, false) + + if (canHandleMimeType(intent.type) || intent.hasExtra(COMPOSE_OPTIONS)) { + // Sharing to Tusky from an external app + if (accountRequested) { + // The correct account is already active + forwardToComposeActivity(intent) + } else { + // No account was provided, show the chooser + showAccountChooserDialog( + getString(R.string.action_share_as), + true, + object : AccountSelectionListener { + override fun onAccountSelected(account: AccountEntity) { + val requestedId = account.id + if (requestedId == activeAccount.id) { + // The correct account is already active + forwardToComposeActivity(intent) + } else { + // A different account was requested, restart the activity + intent.putExtra(TUSKY_ACCOUNT_ID, requestedId) + changeAccount(requestedId, intent) } } } - is NotificationsLoadingEvent -> { - if (event.accountId == activeAccount.accountId) { - showDirectMessageBadge(false) - } - } - is ConversationsLoadingEvent -> { - if (event.accountId == activeAccount.accountId) { - showDirectMessageBadge(false) - } - } - } + ) + } + } else if (openDrafts) { + val draftsIntent = DraftsActivity.newIntent(this) + startActivity(draftsIntent) + } else if (accountRequested && intent.hasExtra(NOTIFICATION_TYPE)) { + // user clicked a notification, show follow requests for type FOLLOW_REQUEST, + // otherwise show notification tab + if (intent.getStringExtra(NOTIFICATION_TYPE) == Notification.Type.FollowRequest.name) { + val accountListIntent = AccountListActivity.newIntent( + this, + AccountListActivity.Type.FOLLOW_REQUESTS + ) + startActivityWithSlideInAnimation(accountListIntent) + } else { + return true } } - - externalScope.launch(Dispatchers.IO) { - // Flush old media that was cached for sharing - deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky")) - } - - selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "") - - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - - if ( - Build.VERSION.SDK_INT >= 33 && - ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED - ) { - ActivityCompat.requestPermissions( - this, - arrayOf(Manifest.permission.POST_NOTIFICATIONS), - 1 - ) - } - - // "Post failed" dialog should display in this activity - draftsAlert.observeInContext(this, true) + return false } - private fun showDirectMessageBadge(showBadge: Boolean) { - directMessageTab?.let { tab -> - tab.badge?.isVisible = showBadge - - // TODO a bit cumbersome (also for resetting) - lifecycleScope.launch(Dispatchers.IO) { - accountManager.activeAccount?.let { - if (it.hasDirectMessageBadge != showBadge) { - it.hasDirectMessageBadge = showBadge - accountManager.saveAccount(it) - } - } - } - } + private fun updateDirectMessageBadge(showBadge: Boolean) { + directMessageTab?.badge?.isVisible = showBadge } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { @@ -482,12 +511,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } } - override fun onStart() { - super.onStart() - // For some reason the navigation drawer is opened when the activity is recreated - if (binding.mainDrawerLayout.isOpen) { - binding.mainDrawerLayout.closeDrawer(GravityCompat.START, false) + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + // Allow software back press to be properly dispatched to drawer layout + val handled = when (event.action) { + KeyEvent.ACTION_DOWN -> binding.mainDrawerLayout.onKeyDown(event.keyCode, event) + KeyEvent.ACTION_UP -> binding.mainDrawerLayout.onKeyUp(event.keyCode, event) + else -> false } + return handled || super.dispatchKeyEvent(event) } override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { @@ -531,12 +562,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } private fun forwardToComposeActivity(intent: Intent) { - val composeOptions = IntentCompat.getParcelableExtra( - intent, - COMPOSE_OPTIONS, - ComposeActivity.ComposeOptions::class.java - ) - + val composeOptions = + intent.getParcelableExtraCompat(COMPOSE_OPTIONS) val composeIntent = if (composeOptions != null) { ComposeActivity.startIntent(this, composeOptions) } else { @@ -544,11 +571,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje action = intent.action type = intent.type putExtras(intent) - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } } startActivity(composeIntent) - finish() } private fun setupDrawer( @@ -627,19 +652,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje ) setSavedInstance(savedInstanceState) } - binding.mainDrawerLayout.addDrawerListener(object : DrawerLayout.DrawerListener { - override fun onDrawerSlide(drawerView: View, slideOffset: Float) { } - - override fun onDrawerOpened(drawerView: View) { - onBackPressedCallback.isEnabled = true - } - - override fun onDrawerClosed(drawerView: View) { - onBackPressedCallback.isEnabled = binding.tabLayout.selectedTabPosition > 0 - } - - override fun onDrawerStateChanged(newState: Int) { } - }) } private fun refreshMainDrawerItems( @@ -714,8 +726,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context)) } badgeStyle = BadgeStyle().apply { - 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)) + textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, materialR.attr.colorOnPrimary)) + color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, materialR.attr.colorPrimary)) } }, DividerDrawerItem(), @@ -801,15 +813,15 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje isEnabled = true iconicsIcon = GoogleMaterial.Icon.gmd_developer_mode onClick = { - buildDeveloperToolsDialog().show() + showDeveloperToolsDialog() } } ) } } - private fun buildDeveloperToolsDialog(): AlertDialog { - return AlertDialog.Builder(this) + private fun showDeveloperToolsDialog(): AlertDialog { + return MaterialAlertDialogBuilder(this) .setTitle("Developer Tools") .setItems( arrayOf("Create \"Load more\" gap") @@ -819,41 +831,32 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje 0 -> { Log.d(TAG, "Creating \"Load more\" gap") lifecycleScope.launch { - accountManager.activeAccount?.let { - developerToolsUseCase.createLoadMoreGap( - it.id - ) - } + developerToolsUseCase.createLoadMoreGap( + activeAccount.id + ) } } } } - .create() + .show() } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(binding.mainDrawer.saveInstanceState(outState)) } - private fun setupTabs(selectNotificationTab: Boolean) { + private fun setupTabs(tabs: List) { 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() binding.bottomTabLayout } else { binding.bottomNav.hide() - (binding.viewPager.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = 0 - (binding.composeButton.layoutParams as CoordinatorLayout.LayoutParams).anchorId = R.id.viewPager binding.tabLayout } // Save the previous tab so it can be restored later val previousTab = tabAdapter.tabs.getOrNull(binding.viewPager.currentItem) - val tabs = accountManager.activeAccount!!.tabPreferences - // Detach any existing mediator before changing tab contents and attaching a new mediator tabLayoutMediator?.detach() @@ -865,27 +868,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje tabLayoutMediator = TabLayoutMediator(activeTabLayout, binding.viewPager, true) { tab: TabLayout.Tab, position: Int -> tab.icon = AppCompatResources.getDrawable(this@MainActivity, tabs[position].icon) - tab.contentDescription = when (tabs[position].id) { - LIST -> tabs[position].arguments[1] - else -> getString(tabs[position].text) - } + tab.contentDescription = tabs[position].title(this) if (tabs[position].id == DIRECT) { val badge = tab.orCreateBadge - badge.isVisible = accountManager.activeAccount?.hasDirectMessageBadge ?: false - badge.backgroundColor = MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorPrimary) + badge.isVisible = activeAccount.hasDirectMessageBadge + badge.backgroundColor = MaterialColors.getColor(binding.mainDrawer, materialR.attr.colorPrimary) directMessageTab = tab } }.also { it.attach() } + updateDirectMessageBadge(viewModel.showDirectMessagesBadge.value) - // 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 + val position = previousTab?.let { tabs.indexOfFirst { it == previousTab } } + .takeIf { it != -1 } ?: 0 binding.viewPager.setCurrentItem(position, false) val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin) @@ -900,21 +894,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje onTabSelectedListener = object : OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab) { - onBackPressedCallback.isEnabled = tab.position > 0 || binding.mainDrawerLayout.isOpen + onBackPressedCallback.isEnabled = tab.position > 0 binding.mainToolbar.title = tab.contentDescription - refreshComposeButtonState(tabAdapter, tab.position) - if (tab == directMessageTab) { - tab.badge?.isVisible = false - - accountManager.activeAccount?.let { - if (it.hasDirectMessageBadge) { - it.hasDirectMessageBadge = false - accountManager.saveAccount(it) - } - } + viewModel.dismissDirectMessagesBadge() } } @@ -923,10 +908,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje override fun onTabReselected(tab: TabLayout.Tab) { val fragment = tabAdapter.getFragment(tab.position) if (fragment is ReselectableFragment) { - (fragment as ReselectableFragment).onReselect() + fragment.onReselect() } - - refreshComposeButtonState(tabAdapter, tab.position) } }.also { activeTabLayout.addOnTabSelectedListener(it) @@ -940,29 +923,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje ) as? ReselectableFragment )?.onReselect() } - - updateProfiles() - } - - 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 { - val activeAccount = accountManager.activeAccount - // open profile when active image was clicked - if (current && activeAccount != null) { + if (current) { val intent = AccountActivity.getIntent(this, activeAccount.accountId) startActivityWithSlideInAnimation(intent) return false @@ -979,98 +944,51 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje return false } - private fun changeAccount(newSelectedId: Long, forward: Intent?) { + private fun changeAccount( + newSelectedId: Long, + forward: Intent?, + ) = lifecycleScope.launch { cacheUpdater.stop() accountManager.setActiveAccount(newSelectedId) - val intent = Intent(this, MainActivity::class.java) - intent.putExtra(OPEN_WITH_EXPLODE_ANIMATION, true) + val intent = Intent(this@MainActivity, MainActivity::class.java) if (forward != null) { intent.type = forward.type intent.action = forward.action intent.putExtras(forward) } + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK startActivity(intent) finish() - if (!supportsOverridingActivityTransitions()) { - @Suppress("DEPRECATION") - overridePendingTransition(R.anim.explode, R.anim.activity_open_exit) - } } private fun logout() { - accountManager.activeAccount?.let { activeAccount -> - AlertDialog.Builder(this) - .setTitle(R.string.action_logout) - .setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName)) - .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> - binding.appBar.hide() - binding.viewPager.hide() - binding.progressBar.show() - binding.bottomNav.hide() - binding.composeButton.hide() + MaterialAlertDialogBuilder(this) + .setTitle(R.string.action_logout) + .setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName)) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + binding.appBar.hide() + binding.viewPager.hide() + binding.progressBar.show() + binding.bottomNav.hide() + binding.composeButton.hide() - lifecycleScope.launch { - val otherAccountAvailable = logoutUsecase.logout() - val intent = if (otherAccountAvailable) { - Intent(this@MainActivity, MainActivity::class.java) - } else { - LoginActivity.getIntent(this@MainActivity, LoginActivity.MODE_DEFAULT) - } - startActivity(intent) - finish() + lifecycleScope.launch { + val otherAccountAvailable = logoutUsecase.logout(activeAccount) + val intent = if (otherAccountAvailable) { + Intent(this@MainActivity, MainActivity::class.java) + } else { + LoginActivity.getIntent(this@MainActivity, LoginActivity.MODE_DEFAULT) } + startActivity(intent) + finish() } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - } - - private fun fetchUserInfo() = lifecycleScope.launch { - mastodonApi.accountVerifyCredentials().fold( - { userInfo -> - onFetchUserInfoSuccess(userInfo) - }, - { throwable -> - Log.e(TAG, "Failed to fetch user info. " + throwable.message) } - ) - } - - private fun onFetchUserInfoSuccess(me: Account) { - Glide.with(header.accountHeaderBackground) - .asBitmap() - .load(me.header) - .into(header.accountHeaderBackground) - - loadDrawerAvatar(me.avatar, false) - - accountManager.updateActiveAccount(me) - NotificationHelper.createNotificationChannelsForAccount( - accountManager.activeAccount!!, - this - ) - - // Setup push notifications - showMigrationNoticeIfNecessary( - this, - binding.mainCoordinatorLayout, - binding.composeButton, - accountManager - ) - if (NotificationHelper.areNotificationsEnabled(this, accountManager)) { - lifecycleScope.launch { - enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager) - } - } else { - disableAllNotifications(this, accountManager) - } - - updateProfiles() - shareShortcutHelper.updateShortcuts() + .setNegativeButton(android.R.string.cancel, null) + .show() } @SuppressLint("CheckResult") - private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) { + private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean = true) { val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false) val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) @@ -1156,22 +1074,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } } - private fun fetchAnnouncements() { - lifecycleScope.launch { - mastodonApi.listAnnouncements(false) - .fold( - { announcements -> - unreadAnnouncementsCount = announcements.count { !it.read } - updateAnnouncementsBadge() - }, - { throwable -> - Log.w(TAG, "Failed to fetch announcements.", throwable) - } - ) - } - } - - private fun updateAnnouncementsBadge() { + private fun updateAnnouncementsBadge(unreadAnnouncementsCount: Int) { binding.mainDrawer.updateBadge( DRAWER_ITEM_ANNOUNCEMENTS, StringHolder( @@ -1180,12 +1083,24 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje ) } - private fun updateProfiles() { + private fun updateProfiles(accounts: List) { + if (accounts.isEmpty()) { + return + } + val activeProfile = accounts.first() + + loadDrawerAvatar(activeProfile.profilePictureUrl) + + Glide.with(header.accountHeaderBackground) + .asBitmap() + .load(activeProfile.profileHeaderUrl) + .into(header.accountHeaderBackground) + val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) val profiles: MutableList = - accountManager.getAllAccountsOrderedByActive().map { acc -> + accounts.map { acc -> ProfileDrawerItem().apply { - isSelected = acc.isActive + isSelected = acc == activeProfile nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis) iconUrl = acc.profilePictureUrl isNameShown = true @@ -1203,9 +1118,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } header.clear() header.profiles = profiles - header.setActiveProfile(accountManager.activeAccount!!.id) - binding.mainToolbar.subtitle = if (accountManager.shouldDisplaySelfUsername(this)) { - accountManager.activeAccount!!.fullName + header.setActiveProfile(activeProfile.id) + binding.mainToolbar.subtitle = if (accountManager.shouldDisplaySelfUsername()) { + activeProfile.fullName } else { null } @@ -1217,8 +1132,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje override fun getActionButton() = binding.composeButton - override fun androidInjector() = androidInjector - companion object { const val OPEN_WITH_EXPLODE_ANIMATION = "explode" @@ -1240,6 +1153,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje fun accountSwitchIntent(context: Context, tuskyAccountId: Long): Intent { return Intent(context, MainActivity::class.java).apply { putExtra(TUSKY_ACCOUNT_ID, tuskyAccountId) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } } @@ -1286,7 +1200,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje fun redirectIntent(context: Context, tuskyAccountId: Long, url: String): Intent { return accountSwitchIntent(context, tuskyAccountId).apply { putExtra(REDIRECT_URL, url) - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } } diff --git a/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt new file mode 100644 index 000000000..696db0d7c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt @@ -0,0 +1,201 @@ +/* Copyright 2024 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 + +import android.content.Context +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.fold +import com.keylesspalace.tusky.appstore.AnnouncementReadEvent +import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.NewNotificationsEvent +import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent +import com.keylesspalace.tusky.components.systemnotifications.NotificationService +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.entity.AccountEntity +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.ShareShortcutHelper +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class MainViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val api: MastodonApi, + private val eventHub: EventHub, + private val accountManager: AccountManager, + private val shareShortcutHelper: ShareShortcutHelper, + private val notificationService: NotificationService, +) : ViewModel() { + + private val activeAccount = accountManager.activeAccount!! + + val accounts: StateFlow> = accountManager.accountsFlow + .map { accounts -> + accounts.map { account -> + AccountViewData( + id = account.id, + domain = account.domain, + username = account.username, + displayName = account.displayName, + profilePictureUrl = account.profilePictureUrl, + profileHeaderUrl = account.profileHeaderUrl, + emojis = account.emojis + ) + } + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val tabs: StateFlow> = accountManager.activeAccount(viewModelScope) + .mapNotNull { account -> account?.tabPreferences } + .stateIn(viewModelScope, SharingStarted.Eagerly, activeAccount.tabPreferences) + + private val _unreadAnnouncementsCount = MutableStateFlow(0) + val unreadAnnouncementsCount: StateFlow = _unreadAnnouncementsCount.asStateFlow() + + val showDirectMessagesBadge: StateFlow = accountManager.activeAccount(viewModelScope) + .map { account -> account?.hasDirectMessageBadge == true } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + init { + loadAccountData() + fetchAnnouncements() + collectEvents() + } + + private fun loadAccountData() { + viewModelScope.launch { + api.accountVerifyCredentials().fold( + { userInfo -> + accountManager.updateAccount(activeAccount, userInfo) + + shareShortcutHelper.updateShortcuts() + + setupNotifications(activeAccount) + }, + { throwable -> + Log.e(TAG, "Failed to fetch user info.", throwable) + } + ) + } + } + + private fun fetchAnnouncements() { + viewModelScope.launch { + api.announcements() + .fold( + { announcements -> + _unreadAnnouncementsCount.value = announcements.count { !it.read } + }, + { throwable -> + Log.w(TAG, "Failed to fetch announcements.", throwable) + } + ) + } + } + + private fun collectEvents() { + viewModelScope.launch { + eventHub.events.collect { event -> + when (event) { + is AnnouncementReadEvent -> { + _unreadAnnouncementsCount.value-- + } + is NewNotificationsEvent -> { + if (event.accountId == activeAccount.accountId) { + val hasDirectMessageNotification = + event.notifications.any { + it.type == Notification.Type.Mention && it.status?.visibility == Status.Visibility.DIRECT + } + + if (hasDirectMessageNotification) { + accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = true) } + } + } + } + is NotificationsLoadingEvent -> { + if (event.accountId == activeAccount.accountId) { + accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) } + } + } + is ConversationsLoadingEvent -> { + if (event.accountId == activeAccount.accountId) { + accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) } + } + } + } + } + } + } + + fun dismissDirectMessagesBadge() { + viewModelScope.launch { + accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) } + } + } + + fun setupNotifications(account: AccountEntity? = null) { + // TODO this is only called on full app (re) start; so changes in-between (push distributor uninstalled/subscription changed, or + // notifications fully disabled) will get unnoticed; and also an app restart cannot be easily triggered by the user. + + if (account != null) { + // TODO it's quite odd to separate channel creation (for an account) from the "is enabled by channels" question below + + notificationService.createNotificationChannelsForAccount(account) + } + + if (notificationService.areNotificationsEnabledBySystem()) { + viewModelScope.launch { + notificationService.setupNotifications(account) + } + } else { + viewModelScope.launch { + notificationService.disableAllNotifications() + } + } + } + + companion object { + private const val TAG = "MainViewModel" + } +} + +data class AccountViewData( + val id: Long, + val domain: String, + val username: String, + val displayName: String, + val profilePictureUrl: String, + val profileHeaderUrl: String, + val emojis: List +) { + val fullName: String + get() = "@$username@$domain" +} diff --git a/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt deleted file mode 100644 index 638f0e5be..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* Copyright 2018 Conny Duck - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky - -import android.annotation.SuppressLint -import android.content.Intent -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import com.keylesspalace.tusky.components.login.LoginActivity -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.di.Injectable -import javax.inject.Inject - -@SuppressLint("CustomSplashScreen") -class SplashActivity : AppCompatActivity(), Injectable { - - @Inject - lateinit var accountManager: AccountManager - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - /** Determine whether the user is currently logged in, and if so go ahead and load the - * timeline. Otherwise, start the activity_login screen. */ - val intent = if (accountManager.activeAccount != null) { - Intent(this, MainActivity::class.java) - } else { - LoginActivity.getIntent(this, LoginActivity.MODE_DEFAULT) - } - startActivity(intent) - finish() - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index c844f2256..d0cbc949d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -26,8 +26,9 @@ import androidx.lifecycle.lifecycleScope 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.appstore.FilterUpdatedEvent import com.keylesspalace.tusky.components.filters.EditFilterActivity +import com.keylesspalace.tusky.components.filters.FilterExpiration import com.keylesspalace.tusky.components.filters.FiltersActivity import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind @@ -37,15 +38,12 @@ import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.viewBinding -import dagger.android.DispatchingAndroidInjector -import dagger.android.HasAndroidInjector +import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.launch -class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { - - @Inject - lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector +@AndroidEntryPoint +class StatusListActivity : BottomSheetActivity() { @Inject lateinit var eventHub: EventHub @@ -78,7 +76,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { val title = when (kind) { Kind.FAVOURITES -> getString(R.string.title_favourites) Kind.BOOKMARKS -> getString(R.string.title_bookmarks) - Kind.TAG -> getString(R.string.title_tag).format(hashtag) + Kind.TAG -> getString(R.string.hashtag_format, hashtag) Kind.PUBLIC_TRENDING_STATUSES -> getString(R.string.title_public_trending_statuses) else -> intent.getStringExtra(EXTRA_LIST_TITLE) } @@ -215,12 +213,12 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { mastodonApi.getFiltersV1().fold( { filters -> mutedFilterV1 = filters.firstOrNull { filter -> - hashedTag == filter.phrase && filter.context.contains(FilterV1.HOME) + hashedTag == filter.phrase && filter.context.contains(Filter.Kind.HOME.kind) } updateTagMuteState(mutedFilterV1 != null) }, - { throwable -> - Log.e(TAG, "Error getting filters: $throwable") + { throwable2 -> + Log.e(TAG, "Error getting filters: $throwable2") } ) } else { @@ -252,9 +250,9 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { mastodonApi.createFilter( title = "#$tag", - context = listOf(FilterV1.HOME), + context = listOf(Filter.Kind.HOME.kind), filterAction = Filter.Action.WARN.action, - expiresInSeconds = null + expiresIn = FilterExpiration.never ).fold( { filter -> if (mastodonApi.addFilterKeyword( @@ -266,8 +264,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { // must be requested again; otherwise does not contain the keyword (but server does) mutedFilter = mastodonApi.getFilter(filter.id).getOrNull() - // TODO the preference key here ("home") is not meaningful; should probably be another event if any - eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) + eventHub.dispatch(FilterUpdatedEvent(filter.context)) filterCreateSuccess = true } else { Snackbar.make( @@ -282,23 +279,23 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { if (throwable.isHttpNotFound()) { mastodonApi.createFilterV1( hashedTag, - listOf(FilterV1.HOME), + listOf(Filter.Kind.HOME.kind), irreversible = false, wholeWord = true, - expiresInSeconds = null + expiresIn = FilterExpiration.never ).fold( { filter -> mutedFilterV1 = filter - eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) + eventHub.dispatch(FilterUpdatedEvent(filter.context)) filterCreateSuccess = true }, - { throwable -> + { throwable2 -> Snackbar.make( binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT ).show() - Log.e(TAG, "Failed to mute #$tag", throwable) + Log.e(TAG, "Failed to mute #$tag", throwable2) } ) } else { @@ -359,10 +356,10 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { mastodonApi.updateFilterV1( id = filter.id, phrase = filter.phrase, - context = filter.context.filter { it != FilterV1.HOME }, + context = filter.context.filter { it != Filter.Kind.HOME.kind }, irreversible = null, wholeWord = null, - expiresInSeconds = null + expiresIn = FilterExpiration.never ) } else { mastodonApi.deleteFilterV1(filter.id) @@ -375,7 +372,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { result?.fold( { updateTagMuteState(false) - eventHub.dispatch(PreferenceChangedEvent(Filter.Kind.HOME.kind)) + eventHub.dispatch(FilterUpdatedEvent(listOf(Filter.Kind.HOME.kind))) mutedFilterV1 = null mutedFilter = null @@ -399,8 +396,6 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { return true } - override fun androidInjector() = dispatchingAndroidInjector - companion object { private const val EXTRA_KIND = "kind" diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index 12c0346cb..9f23f74f8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -20,10 +20,10 @@ 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.components.trending.TrendingTagsFragment -import com.keylesspalace.tusky.fragment.NotificationsFragment import java.util.Objects /** this would be a good case for a sealed class, but that does not work nice with Room */ @@ -60,7 +60,7 @@ data class TabData( override fun hashCode() = Objects.hash(id, arguments) } -fun List.hasTab(id: String): Boolean = this.find { it.id == id } != null +fun List.hasTab(id: String): Boolean = this.any { it.id == id } fun createTabDataFromId(id: String, arguments: List = emptyList()): TabData { return when (id) { @@ -118,7 +118,7 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD arguments = arguments, title = { context -> arguments.joinToString(separator = " ") { - context.getString(R.string.title_tag, it) + context.getString(R.string.hashtag_format, it) } } ) diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index 23d8450cb..90c2e1df9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -18,41 +18,37 @@ package com.keylesspalace.tusky import android.graphics.Color import android.os.Bundle import android.view.View -import android.widget.FrameLayout +import android.view.ViewGroup import androidx.activity.OnBackPressedCallback -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.widget.AppCompatEditText -import androidx.core.view.updatePadding -import androidx.core.widget.doOnTextChanged +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat.Type.systemBars +import androidx.core.view.updateLayoutParams import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.transition.TransitionManager -import at.connyduck.sparkbutton.helpers.Utils import com.google.android.material.transition.MaterialArcMotion import com.google.android.material.transition.MaterialContainerTransform import com.keylesspalace.tusky.adapter.ItemInteractionListener import com.keylesspalace.tusky.adapter.TabAdapter import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.MainTabsChangedEvent import com.keylesspalace.tusky.components.account.list.ListSelectionFragment import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding -import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.ensureBottomPadding import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible -import dagger.android.DispatchingAndroidInjector -import dagger.android.HasAndroidInjector -import java.util.regex.Pattern +import com.keylesspalace.tusky.view.showHashtagPickerDialog +import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, ItemInteractionListener, ListSelectionFragment.ListSelectionListener { +@AndroidEntryPoint +class TabPreferenceActivity : BaseActivity(), ItemInteractionListener, ListSelectionFragment.ListSelectionListener { @Inject lateinit var mastodonApi: MastodonApi @@ -60,9 +56,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, It @Inject lateinit var eventHub: EventHub - @Inject - lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector - private val binding by viewBinding(ActivityTabPreferenceBinding::inflate) private lateinit var currentTabs: MutableList @@ -70,16 +63,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, It private lateinit var touchHelper: ItemTouchHelper private lateinit var addTabAdapter: TabAdapter - private var tabsChanged = false - private val selectedItemElevation by unsafeLazy { resources.getDimension(R.dimen.selected_drag_item_elevation) } - private val hashtagRegex by unsafeLazy { - Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) - } - private val onFabDismissedCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { toggleFab(false) @@ -99,6 +86,19 @@ class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, It setDisplayShowHomeEnabled(true) } + binding.currentTabsRecyclerView.ensureBottomPadding(fab = true) + ViewCompat.setOnApplyWindowInsetsListener(binding.actionButton) { _, insets -> + val bottomInset = insets.getInsets(systemBars()).bottom + val actionButtonMargin = resources.getDimensionPixelSize(R.dimen.fabMargin) + binding.actionButton.updateLayoutParams { + bottomMargin = bottomInset + actionButtonMargin + } + binding.sheet.updateLayoutParams { + bottomMargin = bottomInset + actionButtonMargin + } + insets.inset(0, 0, 0, bottomInset) + } + currentTabs = accountManager.activeAccount?.tabPreferences.orEmpty().toMutableList() currentTabsAdapter = TabAdapter(currentTabs, false, this, currentTabs.size <= MIN_TAB_COUNT) binding.currentTabsRecyclerView.adapter = currentTabsAdapter @@ -233,44 +233,21 @@ class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, It } 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) + showHashtagPickerDialog(mastodonApi, R.string.add_hashtag_title) { hashtag -> + if (tab == null) { + val newTab = createTabDataFromId(HASHTAG, listOf(hashtag)) + currentTabs.add(newTab) + currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) + } else { + val newTab = tab.copy(arguments = tab.arguments + hashtag) + currentTabs[tabPosition] = newTab - val editText = AppCompatEditText(this) - editText.setHint(R.string.edit_hashtag_hint) - editText.setText("") - frameLayout.addView(editText) - - val dialog = AlertDialog.Builder(this) - .setTitle(R.string.add_hashtag_title) - .setView(frameLayout) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.action_save) { _, _ -> - val input = editText.text.toString().trim() - if (tab == null) { - val newTab = createTabDataFromId(HASHTAG, listOf(input)) - currentTabs.add(newTab) - currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) - } else { - val newTab = tab.copy(arguments = tab.arguments + input) - currentTabs[tabPosition] = newTab - - currentTabsAdapter.notifyItemChanged(tabPosition) - } - - updateAvailableTabs() - saveTabs() + currentTabsAdapter.notifyItemChanged(tabPosition) } - .create() - editText.doOnTextChanged { s, _, _, _ -> - dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(s) + updateAvailableTabs() + saveTabs() } - - dialog.show() - dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(editText.text) - editText.requestFocus() } private var listSelectDialog: ListSelectionFragment? = null @@ -293,11 +270,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, It saveTabs() } - private fun validateHashtag(input: CharSequence?): Boolean { - val trimmedInput = input?.trim() ?: "" - return trimmedInput.isNotEmpty() && hashtagRegex.matcher(trimmedInput).matches() - } - private fun updateAvailableTabs() { val addableTabs: MutableList = mutableListOf() @@ -351,25 +323,12 @@ class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, It private fun saveTabs() { accountManager.activeAccount?.let { - lifecycleScope.launch(Dispatchers.IO) { - it.tabPreferences = currentTabs - accountManager.saveAccount(it) - } - } - tabsChanged = true - } - - override fun onPause() { - super.onPause() - if (tabsChanged) { lifecycleScope.launch { - eventHub.dispatch(MainTabsChangedEvent(currentTabs)) + accountManager.updateAccount(it) { copy(tabPreferences = currentTabs) } } } } - override fun androidInjector() = dispatchingAndroidInjector - companion object { private const val MIN_TAB_COUNT = 2 } diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index 0476903a8..8d3efbee0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -16,14 +16,16 @@ package com.keylesspalace.tusky import android.app.Application +import android.app.NotificationManager import android.content.SharedPreferences +import android.os.Build import android.util.Log +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager -import com.keylesspalace.tusky.components.notifications.NotificationHelper -import com.keylesspalace.tusky.di.AppInjector import com.keylesspalace.tusky.settings.AppTheme import com.keylesspalace.tusky.settings.NEW_INSTALL_SCHEMA_VERSION import com.keylesspalace.tusky.settings.PrefKeys @@ -32,9 +34,7 @@ import com.keylesspalace.tusky.settings.SCHEMA_VERSION import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.setAppNightMode import com.keylesspalace.tusky.worker.PruneCacheWorker -import com.keylesspalace.tusky.worker.WorkerFactory -import dagger.android.DispatchingAndroidInjector -import dagger.android.HasAndroidInjector +import dagger.hilt.android.HiltAndroidApp import de.c1710.filemojicompat_defaults.DefaultEmojiPackList import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper import de.c1710.filemojicompat_ui.helpers.EmojiPreference @@ -43,18 +43,20 @@ import java.util.concurrent.TimeUnit import javax.inject.Inject import org.conscrypt.Conscrypt -class TuskyApplication : Application(), HasAndroidInjector { - @Inject - lateinit var androidInjector: DispatchingAndroidInjector +@HiltAndroidApp +class TuskyApplication : Application(), Configuration.Provider { @Inject - lateinit var workerFactory: WorkerFactory + lateinit var workerFactory: HiltWorkerFactory @Inject lateinit var localeManager: LocaleManager @Inject - lateinit var sharedPreferences: SharedPreferences + lateinit var preferences: SharedPreferences + + @Inject + lateinit var notificationManager: NotificationManager override fun onCreate() { // Uncomment me to get StrictMode violation logs @@ -71,14 +73,27 @@ class TuskyApplication : Application(), HasAndroidInjector { Security.insertProviderAt(Conscrypt.newProvider(), 1) - AppInjector.init(this) + val workManager = WorkManager.getInstance(this) // Migrate shared preference keys and defaults from version to version. - val oldVersion = sharedPreferences.getInt( + val oldVersion = preferences.getInt( PrefKeys.SCHEMA_VERSION, NEW_INSTALL_SCHEMA_VERSION ) if (oldVersion != SCHEMA_VERSION) { + if (oldVersion < 2025021701) { + // A new periodic work request is enqueued by unique name (and not tag anymore): stop the old one + workManager.cancelAllWorkByTag("pullNotifications") + } + if (oldVersion < 2025022001 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // delete old now unused notification channels + for (channel in notificationManager.notificationChannels) { + if (channel.id.startsWith("CHANNEL_SIGN_UP") || channel.id.startsWith("CHANNEL_REPORT")) { + notificationManager.deleteNotificationChannel(channel.id) + } + } + } + upgradeSharedPreferences(oldVersion, SCHEMA_VERSION) } @@ -88,36 +103,30 @@ class TuskyApplication : Application(), HasAndroidInjector { EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false) // init night mode - val theme = sharedPreferences.getString(APP_THEME, AppTheme.DEFAULT.value) + val theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.value) setAppNightMode(theme) localeManager.setLocale() - NotificationHelper.createWorkerNotificationChannel(this) - - WorkManager.initialize( - this, - androidx.work.Configuration.Builder() - .setWorkerFactory(workerFactory) - .build() - ) - // Prune the database every ~ 12 hours when the device is idle. val pruneCacheWorker = PeriodicWorkRequestBuilder(12, TimeUnit.HOURS) .setConstraints(Constraints.Builder().setRequiresDeviceIdle(true).build()) .build() - WorkManager.getInstance(this).enqueueUniquePeriodicWork( + workManager.enqueueUniquePeriodicWork( PruneCacheWorker.PERIODIC_WORK_TAG, ExistingPeriodicWorkPolicy.KEEP, pruneCacheWorker ) } - override fun androidInjector() = androidInjector + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() private fun upgradeSharedPreferences(oldVersion: Int, newVersion: Int) { Log.d(TAG, "Upgrading shared preferences: $oldVersion -> $newVersion") - val editor = sharedPreferences.edit() + val editor = preferences.edit() if (oldVersion < 2023022701) { // These preferences are (now) handled in AccountPreferenceHandler. Remove them from shared for clarity. @@ -127,17 +136,11 @@ class TuskyApplication : Application(), HasAndroidInjector { editor.remove(PrefKeys.MEDIA_PREVIEW_ENABLED) } - if (oldVersion < 2023072401) { - // The notifications filter / clear options are shown on a menu, not a separate bar, - // the preference to display them is not needed. - editor.remove(PrefKeys.Deprecated.SHOW_NOTIFICATIONS_FILTER) - } - if (oldVersion != NEW_INSTALL_SCHEMA_VERSION && oldVersion < 2023082301) { // Default value for appTheme is now THEME_SYSTEM. If the user is upgrading and // didn't have an explicit preference set use the previous default, so the // theme does not unexpectedly change. - if (!sharedPreferences.contains(APP_THEME)) { + if (!preferences.contains(APP_THEME)) { editor.putString(APP_THEME, AppTheme.NIGHT.value) } } @@ -148,6 +151,10 @@ class TuskyApplication : Application(), HasAndroidInjector { editor.remove(PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS) } + if (oldVersion < 2024060201) { + editor.remove(PrefKeys.Deprecated.FAB_HIDE) + } + editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion) editor.apply() } diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt index 18654ec5c..1430327ab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -19,11 +19,8 @@ import android.Manifest import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.app.DownloadManager -import android.content.ClipData -import android.content.ClipboardManager import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.Color import android.net.Uri @@ -38,14 +35,15 @@ import android.view.View import android.view.WindowManager import android.webkit.MimeTypeMap import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.core.app.ShareCompat import androidx.core.content.FileProvider -import androidx.core.content.IntentCompat import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 import com.bumptech.glide.Glide +import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding @@ -54,32 +52,29 @@ import com.keylesspalace.tusky.fragment.ViewImageFragment import com.keylesspalace.tusky.fragment.ViewVideoFragment import com.keylesspalace.tusky.pager.ImagePagerAdapter import com.keylesspalace.tusky.pager.SingleImagePagerAdapter +import com.keylesspalace.tusky.util.copyToClipboard +import com.keylesspalace.tusky.util.getParcelableArrayListExtraCompat import com.keylesspalace.tusky.util.getTemporaryMediaFilename import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.submitAsync import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData -import dagger.android.DispatchingAndroidInjector -import dagger.android.HasAndroidInjector +import dagger.hilt.android.AndroidEntryPoint import java.io.File import java.io.FileOutputStream import java.io.IOException import java.util.Locale -import javax.inject.Inject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit +@AndroidEntryPoint class ViewMediaActivity : BaseActivity(), - HasAndroidInjector, ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener { - @Inject - lateinit var androidInjector: DispatchingAndroidInjector - private val binding by viewBinding(ActivityViewMediaBinding::inflate) val toolbar: View @@ -92,6 +87,17 @@ class ViewMediaActivity : private val toolbarVisibilityListeners = mutableListOf() private var imageUrl: String? = null + private val requestDownloadMediaPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) { + downloadMedia() + } else { + Snackbar.make(binding.toolbar, getString(R.string.error_media_download_permission), Snackbar.LENGTH_SHORT) + .setAction(R.string.action_retry) { requestDownloadMedia() } + .show() + } + } + fun addToolbarVisibilityListener(listener: ToolbarVisibilityListener): Function0 { this.toolbarVisibilityListeners.add(listener) listener(isToolbarVisible) @@ -105,11 +111,7 @@ class ViewMediaActivity : supportPostponeEnterTransition() // Gather the parameters. - attachments = IntentCompat.getParcelableArrayListExtra( - intent, - EXTRA_ATTACHMENTS, - AttachmentViewData::class.java - ) + attachments = intent.getParcelableArrayListExtraCompat(EXTRA_ATTACHMENTS) val initialPosition = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0) // Adapter is actually of existential type PageAdapter & SharedElementsTransitionListener @@ -154,9 +156,13 @@ class ViewMediaActivity : true } + // yes it is deprecated, but it looks cool so it stays for now window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LOW_PROFILE - window.statusBarColor = Color.BLACK + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) { + @Suppress("DEPRECATION") + window.statusBarColor = Color.BLACK + } window.sharedElementEnterTransition.addListener(object : NoopTransitionListener { override fun onTransitionEnd(transition: Transition) { adapter.onTransitionEnd(binding.viewPager.currentItem) @@ -235,22 +241,7 @@ class ViewMediaActivity : private fun requestDownloadMedia() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - requestPermissions( - arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE) - ) { _, grantResults -> - if ( - grantResults.isNotEmpty() && - grantResults[0] == PackageManager.PERMISSION_GRANTED - ) { - downloadMedia() - } else { - showErrorDialog( - binding.toolbar, - R.string.error_media_download_permission, - R.string.action_retry - ) { requestDownloadMedia() } - } - } + requestDownloadMediaPermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) } else { downloadMedia() } @@ -264,9 +255,10 @@ class ViewMediaActivity : } private fun copyLink() { - val url = imageUrl ?: attachments!![binding.viewPager.currentItem].attachment.url - val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText(null, url)) + copyToClipboard( + imageUrl ?: attachments!![binding.viewPager.currentItem].attachment.url, + getString(R.string.url_copied), + ) } private fun shareMedia() { @@ -367,8 +359,6 @@ class ViewMediaActivity : } } - override fun androidInjector() = androidInjector - companion object { private const val EXTRA_ATTACHMENTS = "attachments" private const val EXTRA_ATTACHMENT_INDEX = "index" diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt index e2f461201..8d42f0a9f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt @@ -20,15 +20,17 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter -import androidx.preference.PreferenceManager import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAutocompleteAccountBinding -import com.keylesspalace.tusky.db.AccountEntity -import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.loadAvatar -class AccountSelectionAdapter(context: Context) : ArrayAdapter( +class AccountSelectionAdapter( + context: Context, + private val animateAvatars: Boolean, + private val animateEmojis: Boolean +) : ArrayAdapter( context, R.layout.item_autocomplete_account ) { @@ -42,17 +44,13 @@ class AccountSelectionAdapter(context: Context) : ArrayAdapter( val account = getItem(position) if (account != null) { - val pm = PreferenceManager.getDefaultSharedPreferences(binding.avatar.context) - val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) - binding.username.text = account.fullName binding.displayName.text = account.displayName.emojify(account.emojis, binding.displayName, animateEmojis) binding.avatarBadge.visibility = View.GONE // We never want to display the bot badge here val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp) - val animateAvatar = pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) - loadAvatar(account.profilePictureUrl, binding.avatar, avatarRadius, animateAvatar) + loadAvatar(account.profilePictureUrl, binding.avatar, avatarRadius, animateAvatars) } return binding.root diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt index f2162fad7..f615eb00c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt @@ -33,6 +33,7 @@ class EmojiAdapter( private val emojiList: List = emojiList.filter { emoji -> emoji.visibleInPicker } .sortedBy { it.shortcode.lowercase(Locale.ROOT) } + .sortedBy { it.category?.lowercase(Locale.ROOT) ?: "" } override fun getItemCount() = emojiList.size diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FilteredStatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FilteredStatusViewHolder.kt new file mode 100644 index 000000000..3b054df92 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FilteredStatusViewHolder.kt @@ -0,0 +1,61 @@ +/* Copyright 2024 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.components.notifications.NotificationsViewHolder +import com.keylesspalace.tusky.databinding.ItemStatusFilteredBinding +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.FilterResult +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.keylesspalace.tusky.viewdata.StatusViewData + +class FilteredStatusViewHolder( + private val binding: ItemStatusFilteredBinding, + listener: StatusActionListener +) : NotificationsViewHolder, RecyclerView.ViewHolder(binding.root) { + + init { + binding.statusFilterShowAnyway.setOnClickListener { + listener.clearWarningAction(bindingAdapterPosition) + } + } + + override fun bind( + viewData: NotificationViewData.Concrete, + payloads: List<*>, + statusDisplayOptions: StatusDisplayOptions + ) { + if (payloads.isEmpty()) { + bind(viewData.statusViewData!!) + } + } + + fun bind(viewData: StatusViewData.Concrete) { + val matchedFilterResult: FilterResult? = viewData.actionable.filtered.orEmpty().find { filterResult -> + filterResult.filter.action == Filter.Action.WARN + } + + val matchedFilterTitle = matchedFilterResult?.filter?.title.orEmpty() + + binding.statusFilterLabel.text = itemView.context.getString( + R.string.status_filter_placeholder_label_format, + matchedFilterTitle + ) + } +} 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 902d36bcb..388f1a270 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt @@ -21,10 +21,12 @@ 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.NotificationsViewHolder 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 @@ -33,12 +35,31 @@ 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 accountListener: AccountActionListener, private val linkListener: LinkListener, private val showHeader: Boolean -) : RecyclerView.ViewHolder(binding.root) { +) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder { + + override fun bind( + viewData: NotificationViewData.Concrete, + payloads: List<*>, + statusDisplayOptions: StatusDisplayOptions + ) { + if (payloads.isNotEmpty()) { + return + } + setupWithAccount( + viewData.account, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.animateEmojis, + statusDisplayOptions.showBotOverlay + ) + setupActionListener(accountListener, viewData.account.id) + } fun setupWithAccount( account: TimelineAccount, @@ -98,5 +119,6 @@ class FollowRequestViewHolder( } } itemView.setOnClickListener { listener.onViewAccount(accountId) } + binding.accountNote.setOnClickListener { listener.onViewAccount(accountId) } } } 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 08d702d5e..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ /dev/null @@ -1,708 +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.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.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 java.util.Date; -import java.util.List; - -import at.connyduck.sparkbutton.helpers.Utils; - -public class NotificationsAdapter extends RecyclerView.Adapter implements LinkListener{ - - 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 final String accountId; - private StatusDisplayOptions statusDisplayOptions; - private final StatusActionListener statusListener; - private final NotificationActionListener notificationActionListener; - private final AccountActionListener accountActionListener; - private final 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, this, 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().getDisplayName(), 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.showStatsInline(), - statusDisplayOptions.showSensitiveMedia(), - statusDisplayOptions.openSpoiler() - ); - } - - 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 final TextView message; - private final TextView usernameView; - private final TextView displayNameView; - private final ImageView avatar; - private final 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(), null); - - } - - 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 View container; - 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 final StatusDisplayOptions statusDisplayOptions; - private final AbsoluteTimeFormatter absoluteTimeFormatter; - - private String accountId; - private String notificationId; - private NotificationActionListener notificationActionListener; - private StatusViewData.Concrete statusViewData; - - private final int avatarRadius48dp; - private final int avatarRadius36dp; - private final 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); - - container = itemView.findViewById(R.id.notification_container); - - this.statusDisplayOptions = statusDisplayOptions; - this.absoluteTimeFormatter = absoluteTimeFormatter; - - 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.chinwag_green); - format = context.getString(R.string.notification_subscription_format); - break; - } - case UPDATE: { - icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_green); - 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(), null); - - if (statusDisplayOptions.showBotOverlay() && isBot) { - notificationAvatar.setVisibility(View.VISIBLE); - Glide.with(notificationAvatar) - .load(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(), null); - - notificationAvatar.setVisibility(View.VISIBLE); - ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar, - avatarRadius24dp, statusDisplayOptions.animateAvatars(), null); - } - - @Override - public void onClick(View v) { - if (notificationActionListener == null) - return; - - if (v == container || v == statusContent) { - notificationActionListener.onViewStatusForNotificationId(notificationId); - } - else if (v == message) { - notificationActionListener.onViewAccount(accountId); - } - } - - 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 = CustomEmojiHelper.emojify( - statusViewData.getStatus().getSpoilerText(), - statusViewData.getActionable().getEmojis(), - contentWarningDescriptionTextView, - statusDisplayOptions.animateEmojis() - ); - contentWarningDescriptionTextView.setText(emojifiedContentWarning); - } - - } - - - @Override - public void onViewTag(@NonNull String tag) { - - } - - @Override - public void onViewAccount(@NonNull String id) { - - } - - @Override - public void onViewUrl(@NonNull String url) { - - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt index c277ea385..d64780a60 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt @@ -14,54 +14,33 @@ * see . */ package com.keylesspalace.tusky.adapter -import android.view.View import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.button.MaterialButton -import com.google.android.material.progressindicator.CircularProgressIndicatorSpec -import com.google.android.material.progressindicator.IndeterminateDrawable -import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.visible /** - * Placeholder for different timelines. + * Placeholder for missing parts in timelines. * - * Displays a "Load more" button for a particular status ID, or a - * circular progress wheel if the status' page is being loaded. - * - * The user can only have one "Load more" operation in progress at - * a time (determined by the adapter), so the contents of the view - * and the enabled state is driven by that. + * Displays a "Load more" button to load the gap, or a + * circular progress bar if the missing page is being loaded. */ -class PlaceholderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - private val loadMoreButton: MaterialButton = itemView.findViewById(R.id.button_load_more) - private val drawable = IndeterminateDrawable.createCircularDrawable( - itemView.context, - CircularProgressIndicatorSpec(itemView.context, null) - ) +class PlaceholderViewHolder( + private val binding: ItemStatusPlaceholderBinding, + listener: StatusActionListener +) : RecyclerView.ViewHolder(binding.root) { - fun setup(listener: StatusActionListener, loading: Boolean) { - itemView.isEnabled = !loading - loadMoreButton.isEnabled = !loading - - if (loading) { - loadMoreButton.text = "" - loadMoreButton.icon = drawable - return - } - - loadMoreButton.text = itemView.context.getString(R.string.load_more_placeholder_text) - loadMoreButton.icon = null - - // To allow the user to click anywhere in the layout to load more content set the click - // listener on the parent layout instead of loadMoreButton. - // - // See the comments in item_status_placeholder.xml for more details. - itemView.setOnClickListener { - itemView.isEnabled = false - loadMoreButton.isEnabled = false - loadMoreButton.icon = drawable - loadMoreButton.text = "" + init { + binding.loadMoreButton.setOnClickListener { + binding.loadMoreButton.hide() + binding.loadMoreProgressBar.show() listener.onLoadMore(bindingAdapterPosition) } } + + fun setup(loading: Boolean) { + binding.loadMoreButton.visible(!loading) + binding.loadMoreProgressBar.visible(loading) + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt index 10c2f14f7..d24cba8cd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt @@ -19,7 +19,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView -import androidx.core.widget.TextViewCompat import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R @@ -58,7 +57,7 @@ class PreviewPollOptionsAdapter : RecyclerView.Adapter() { R.drawable.ic_radio_button_unchecked_18dp } - TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(textView, iconId, 0, 0, 0) + textView.setCompoundDrawablesRelativeWithIntrinsicBounds(iconId, 0, 0, 0) textView.text = options[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 03267c49b..84da4e170 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -6,9 +6,11 @@ import android.content.Context; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; +import android.net.Uri; import android.text.Spanned; import android.text.TextUtils; import android.text.format.DateUtils; +import android.view.Gravity; import android.view.Menu; import android.view.View; import android.view.ViewGroup; @@ -17,14 +19,14 @@ import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; -import android.widget.Toast; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.widget.PopupMenu; +import androidx.appcompat.widget.TooltipCompat; import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.core.content.ContextCompat; import androidx.core.text.HtmlCompat; import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.LinearLayoutManager; @@ -33,6 +35,7 @@ import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; import com.bumptech.glide.RequestBuilder; import com.google.android.material.button.MaterialButton; +import com.google.android.material.card.MaterialCardView; import com.google.android.material.color.MaterialColors; import com.google.android.material.imageview.ShapeableImageView; import com.google.android.material.shape.CornerFamily; @@ -42,16 +45,16 @@ import com.keylesspalace.tusky.ViewMediaActivity; import com.keylesspalace.tusky.entity.Attachment; 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.PreviewCard; 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.entity.TimelineAccount; import com.keylesspalace.tusky.entity.Translation; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; import com.keylesspalace.tusky.util.AttachmentHelper; +import com.keylesspalace.tusky.util.BlurhashDrawable; import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.CompositeWithOpaqueBackground; import com.keylesspalace.tusky.util.CustomEmojiHelper; @@ -74,7 +77,6 @@ import java.text.NumberFormat; import java.util.Collections; import java.util.Date; import java.util.List; -import java.util.Locale; import at.connyduck.sparkbutton.SparkButton; import at.connyduck.sparkbutton.helpers.Utils; @@ -95,7 +97,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private final SparkButton favouriteButton; private final SparkButton bookmarkButton; private final ImageButton moreButton; - private final ConstraintLayout mediaContainer; + protected final ConstraintLayout mediaContainer; protected final MediaPreviewLayout mediaPreview; private final TextView sensitiveMediaWarning; private final View sensitiveMediaShow; @@ -113,19 +115,19 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private final TextView pollDescription; private final Button pollButton; - private final LinearLayout cardView; - private final LinearLayout cardInfo; + private final MaterialCardView cardView; + private final LinearLayout cardLayout; private final ShapeableImageView cardImage; private final TextView cardTitle; - private final TextView cardDescription; - private final TextView cardUrl; + private final TextView cardMetadata; + private final TextView cardAuthor; + private final TextView cardAuthorButton; + private final PollAdapter pollAdapter; - protected final LinearLayout filteredPlaceholder; - protected final TextView filteredPlaceholderLabel; - protected final Button filteredPlaceholderShowButton; protected final ConstraintLayout statusContainer; private final TextView translationStatusView; private final Button untranslateButton; + private final TextView trailingHashtagView; private final NumberFormat numberFormat = NumberFormat.getNumberInstance(); @@ -173,15 +175,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { pollButton = itemView.findViewById(R.id.status_poll_button); cardView = itemView.findViewById(R.id.status_card_view); - cardInfo = itemView.findViewById(R.id.card_info); + cardLayout = itemView.findViewById(R.id.status_card_layout); cardImage = itemView.findViewById(R.id.card_image); cardTitle = itemView.findViewById(R.id.card_title); - cardDescription = itemView.findViewById(R.id.card_description); - cardUrl = itemView.findViewById(R.id.card_link); + cardMetadata = itemView.findViewById(R.id.card_metadata); + cardAuthor = itemView.findViewById(R.id.card_author); + cardAuthorButton = itemView.findViewById(R.id.card_author_button); - 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(); @@ -191,6 +191,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { translationStatusView = itemView.findViewById(R.id.status_translation_status); untranslateButton = itemView.findViewById(R.id.status_button_untranslate); + trailingHashtagView = itemView.findViewById(R.id.status_trailing_hashtags_content); this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); @@ -201,7 +202,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { TouchDelegateHelper.expandTouchSizeToFillRow((ViewGroup) itemView, CollectionsKt.listOfNotNull(replyButton, reblogButton, favouriteButton, bookmarkButton, moreButton)); } - protected void setDisplayName(@NonNull String name, @Nullable List customEmojis, @NonNull StatusDisplayOptions statusDisplayOptions) { + protected void setDisplayName(@NonNull String name, @NonNull List customEmojis, @NonNull StatusDisplayOptions statusDisplayOptions) { CharSequence emojifiedName = CustomEmojiHelper.emojify( name, customEmojis, displayName, statusDisplayOptions.animateEmojis() ); @@ -235,9 +236,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { ); contentWarningDescription.setText(emojiSpoiler); contentWarningDescription.setVisibility(View.VISIBLE); - contentWarningButton.setVisibility(View.VISIBLE); - setContentWarningButtonText(expanded); - contentWarningButton.setOnClickListener(view -> toggleExpandedState(true, !expanded, status, statusDisplayOptions, listener)); + boolean hasContent = !TextUtils.isEmpty(status.getContent()); + if (hasContent) { + contentWarningButton.setVisibility(View.VISIBLE); + setContentWarningButtonText(expanded); + contentWarningButton.setOnClickListener(view -> toggleExpandedState(true, !expanded, status, statusDisplayOptions, listener)); + } else { + contentWarningButton.setVisibility(View.GONE); + } this.setTextVisible(true, expanded, status, statusDisplayOptions, listener); } else { contentWarningDescription.setVisibility(View.GONE); @@ -287,7 +293,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (expanded) { CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis()); - LinkHelper.setClickableText(this.content, emojifiedText, mentions, tags, listener); + LinkHelper.setClickableText(this.content, emojifiedText, mentions, tags, listener, this.trailingHashtagView); + if (trailingHashtagView != null && status.isCollapsible() && status.isCollapsed()) { + trailingHashtagView.setVisibility(View.GONE); + } for (int i = 0; i < mediaLabels.length; ++i) { updateMediaLabel(i, sensitive, true); } @@ -298,6 +307,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } else { hidePoll(); + if (trailingHashtagView != null) { + trailingHashtagView.setVisibility(View.GONE); + } LinkHelper.setClickableMentions(this.content, mentions, listener); } if (TextUtils.isEmpty(this.content.getText())) { @@ -399,13 +411,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } - protected void setIsReply(boolean isReply) { + protected void setReplyButtonImage(boolean isReply) { if (isReply) { replyButton.setImageResource(R.drawable.ic_reply_all_24dp); } else { replyButton.setImageResource(R.drawable.ic_reply_24dp); } - } protected void setReplyCount(int repliesCount, boolean fullStats) { @@ -463,7 +474,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } private BitmapDrawable decodeBlurHash(String blurhash) { - return ImageLoadingHelper.decodeBlurHash(this.avatar.getContext(), blurhash); + return new BlurhashDrawable(this.avatar.getContext(), blurhash); } private void loadImage(MediaPreviewImageView imageView, @@ -536,12 +547,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { final Attachment.Type type = attachment.getType(); if (showingContent && (type == Attachment.Type.VIDEO || type == Attachment.Type.GIFV)) { - imageView.setForeground(ContextCompat.getDrawable(itemView.getContext(), R.drawable.play_indicator_overlay)); + imageView.setForegroundGravity(Gravity.CENTER); + imageView.setForeground(AppCompatResources.getDrawable(itemView.getContext(), R.drawable.ic_play_indicator)); } else { imageView.setForeground(null); } - setAttachmentClickListener(imageView, listener, i, attachment, true); + final CharSequence formattedDescription = AttachmentHelper.getFormattedDescription(attachment, imageView.getContext()); + setAttachmentClickListener(imageView, listener, i, formattedDescription, true); if (sensitive) { sensitiveMediaWarning.setText(R.string.post_sensitive_media_title); @@ -611,17 +624,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { // Set the icon next to the label. int drawableId = getLabelIcon(attachments.get(0).getType()); - mediaLabel.setCompoundDrawablesWithIntrinsicBounds(drawableId, 0, 0, 0); + mediaLabel.setCompoundDrawablesRelativeWithIntrinsicBounds(drawableId, 0, 0, 0); - setAttachmentClickListener(mediaLabel, listener, i, attachment, false); + setAttachmentClickListener(mediaLabel, listener, i, mediaDescriptions[i], false); } else { mediaLabel.setVisibility(View.GONE); } } } - private void setAttachmentClickListener(View view, @NonNull StatusActionListener listener, - int index, Attachment attachment, boolean animateTransition) { + private void setAttachmentClickListener(@NonNull View view, @NonNull StatusActionListener listener, + int index, CharSequence description, boolean animateTransition) { view.setOnClickListener(v -> { int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { @@ -632,11 +645,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } }); - view.setOnLongClickListener(v -> { - CharSequence description = AttachmentHelper.getFormattedDescription(attachment, view.getContext()); - Toast.makeText(view.getContext(), description, Toast.LENGTH_LONG).show(); - return true; - }); + TooltipCompat.setTooltipText(view, description); } protected void hideSensitiveMediaWarning() { @@ -670,7 +679,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { showConfirmReblog(listener, buttonState, position); return false; } else { - listener.onReblog(!buttonState, position); + listener.onReblog(!buttonState, position, Status.Visibility.PUBLIC); return true; } } else { @@ -731,13 +740,25 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { popup.inflate(R.menu.status_reblog); Menu menu = popup.getMenu(); if (buttonState) { - menu.findItem(R.id.menu_action_reblog).setVisible(false); + menu.setGroupVisible(R.id.menu_action_reblog_group, false); } else { menu.findItem(R.id.menu_action_unreblog).setVisible(false); } popup.setOnMenuItemClickListener(item -> { - listener.onReblog(!buttonState, position); - if (!buttonState) { + if (buttonState) { + listener.onReblog(false, position, Status.Visibility.PUBLIC); + } else { + Status.Visibility visibility; + if (item.getItemId() == R.id.menu_action_reblog_public) { + visibility = Status.Visibility.PUBLIC; + } else if (item.getItemId() == R.id.menu_action_reblog_unlisted) { + visibility = Status.Visibility.UNLISTED; + } else if (item.getItemId() == R.id.menu_action_reblog_private) { + visibility = Status.Visibility.PRIVATE; + } else { + visibility = Status.Visibility.PUBLIC; + } + listener.onReblog(true, position, visibility); reblogButton.playAnimation(); reblogButton.setChecked(true); } @@ -768,21 +789,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { popup.show(); } - public void setupWithStatus(@NonNull StatusViewData.Concrete status, final @NonNull StatusActionListener listener, - @NonNull StatusDisplayOptions statusDisplayOptions) { - this.setupWithStatus(status, listener, statusDisplayOptions, null); - } - public void setupWithStatus(@NonNull StatusViewData.Concrete status, @NonNull final StatusActionListener listener, @NonNull StatusDisplayOptions statusDisplayOptions, - @Nullable Object payloads) { - if (payloads == null) { + @NonNull List payloads, + final boolean showStatusInfo) { + if (payloads.isEmpty()) { Status actionable = status.getActionable(); setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions); setUsername(actionable.getAccount().getUsername()); setMetaData(status, statusDisplayOptions, listener); - setIsReply(actionable.getInReplyToId() != null); + setReplyButtonImage(actionable.isReply()); setReplyCount(actionable.getRepliesCount(), statusDisplayOptions.showStatsInline()); setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(), actionable.getAccount().getBot(), statusDisplayOptions); @@ -791,10 +808,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { setBookmarked(actionable.getBookmarked()); List attachments = status.getAttachments(); boolean sensitive = actionable.getSensitive(); - if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { + if (attachments.isEmpty()) { + mediaContainer.setVisibility(View.GONE); + } else if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { + mediaContainer.setVisibility(View.VISIBLE); + setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash()); - if (attachments.size() == 0) { + if (attachments.isEmpty()) { hideSensitiveMediaWarning(); } // Hide the unused label. @@ -802,6 +823,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { mediaLabel.setVisibility(View.GONE); } } else { + mediaContainer.setVisibility(View.VISIBLE); + setMediaLabel(attachments, sensitive, listener, status.isShowingContent()); // Hide all unused views. mediaPreview.setVisibility(View.GONE); @@ -819,8 +842,6 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { setSpoilerAndContent(status, statusDisplayOptions, listener); - setupFilterPlaceholder(status, listener, statusDisplayOptions); - setDescriptionForStatus(status, statusDisplayOptions); // Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0 @@ -830,13 +851,16 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { // and let RecyclerView ask for a new delegate. itemView.setAccessibilityDelegate(null); } else { - if (payloads instanceof List) - for (Object item : (List) payloads) { - if (Key.KEY_CREATED.equals(item)) { - setMetaData(status, statusDisplayOptions, listener); + for (Object item : payloads) { + if (Key.KEY_CREATED.equals(item)) { + setMetaData(status, statusDisplayOptions, listener); + if (status.getStatus().getCard() != null && status.getStatus().getCard().getPublishedAt() != null) { + // there is a preview card showing the published time, we need to refresh it as well + setupCard(status, status.isExpanded(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); } + break; } - + } } } @@ -863,28 +887,6 @@ 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(@NonNull List attachments) { for (Attachment attachment : attachments) { if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) { @@ -1154,11 +1156,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { return; } + final Context context = cardView.getContext(); + final Status actionable = status.getActionable(); - final Card card = actionable.getCard(); + final PreviewCard card = actionable.getCard(); if (cardViewMode != CardViewMode.NONE && - actionable.getAttachments().size() == 0 && + actionable.getAttachments().isEmpty() && actionable.getPoll() == null && card != null && !TextUtils.isEmpty(card.getUrl()) && @@ -1167,45 +1171,79 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { cardView.setVisibility(View.VISIBLE); cardTitle.setText(card.getTitle()); - if (TextUtils.isEmpty(card.getDescription()) && TextUtils.isEmpty(card.getAuthorName())) { - cardDescription.setVisibility(View.GONE); + + String providerName = card.getProviderName(); + if (TextUtils.isEmpty(providerName)) { + providerName = Uri.parse(card.getUrl()).getHost(); + } + + if (TextUtils.isEmpty(providerName)) { + cardMetadata.setVisibility(View.GONE); } else { - cardDescription.setVisibility(View.VISIBLE); - if (TextUtils.isEmpty(card.getDescription())) { - cardDescription.setText(card.getAuthorName()); + cardMetadata.setVisibility(View.VISIBLE); + if (card.getPublishedAt() == null) { + cardMetadata.setText(providerName); } else { - cardDescription.setText(card.getDescription()); + String metadataJoiner = context.getString(R.string.metadata_joiner); + cardMetadata.setText(providerName + metadataJoiner + TimestampUtils.getRelativeTimeSpanString(context, card.getPublishedAt().getTime(), System.currentTimeMillis())); } } - cardUrl.setText(card.getUrl()); + String cardAuthorName; + final TimelineAccount cardAuthorAccount; + if (card.getAuthors().isEmpty()) { + cardAuthorAccount = null; + cardAuthorName = card.getAuthorName(); + } else { + cardAuthorName = card.getAuthors().get(0).getName(); + cardAuthorAccount = card.getAuthors().get(0).getAccount(); + if (cardAuthorAccount != null) { + cardAuthorName = cardAuthorAccount.getName(); + } + } + + final boolean hasNoAuthorName = TextUtils.isEmpty(cardAuthorName); + + if (hasNoAuthorName && TextUtils.isEmpty(card.getDescription())) { + cardAuthor.setVisibility(View.GONE); + cardAuthorButton.setVisibility(View.GONE); + } else if (hasNoAuthorName) { + cardAuthor.setVisibility(View.VISIBLE); + cardAuthor.setText(card.getDescription()); + cardAuthorButton.setVisibility(View.GONE); + } else if (cardAuthorAccount == null) { + cardAuthor.setVisibility(View.VISIBLE); + cardAuthor.setText(context.getString(R.string.preview_card_by_author, cardAuthorName)); + cardAuthorButton.setVisibility(View.GONE); + } else { + cardAuthorButton.setVisibility(View.VISIBLE); + final String buttonText = context.getString(R.string.preview_card_more_by_author, cardAuthorName); + final CharSequence emojifiedButtonText = CustomEmojiHelper.emojify(buttonText, cardAuthorAccount.getEmojis(), cardAuthorButton, statusDisplayOptions.animateEmojis()); + cardAuthorButton.setText(emojifiedButtonText); + cardAuthorButton.setOnClickListener(v-> listener.onViewAccount(cardAuthorAccount.getId())); + cardAuthor.setVisibility(View.GONE); + } // Statuses from other activitypub sources can be marked sensitive even if there's no media, // so let's blur the preview in that case // If media previews are disabled, show placeholder for cards as well if (statusDisplayOptions.mediaPreviewEnabled() && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) { - int radius = cardImage.getContext().getResources() - .getDimensionPixelSize(R.dimen.card_radius); + int radius = context.getResources().getDimensionPixelSize(R.dimen.inner_card_radius); ShapeAppearanceModel.Builder cardImageShape = ShapeAppearanceModel.builder(); if (card.getWidth() > card.getHeight()) { - cardView.setOrientation(LinearLayout.VERTICAL); - + cardLayout.setOrientation(LinearLayout.VERTICAL); cardImage.getLayoutParams().height = cardImage.getContext().getResources() .getDimensionPixelSize(R.dimen.card_image_vertical_height); cardImage.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; - cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; - cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT; cardImageShape.setTopLeftCorner(CornerFamily.ROUNDED, radius); cardImageShape.setTopRightCorner(CornerFamily.ROUNDED, radius); } else { - cardView.setOrientation(LinearLayout.HORIZONTAL); + cardLayout.setOrientation(LinearLayout.HORIZONTAL); cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; cardImage.getLayoutParams().width = cardImage.getContext().getResources() .getDimensionPixelSize(R.dimen.card_image_horizontal_width); - cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT; - cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; cardImageShape.setTopLeftCorner(CornerFamily.ROUNDED, radius); cardImageShape.setBottomLeftCorner(CornerFamily.ROUNDED, radius); } @@ -1215,22 +1253,20 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { cardImage.setScaleType(ImageView.ScaleType.CENTER_CROP); RequestBuilder builder = Glide.with(cardImage.getContext()) - .load(card.getImage()) - .dontTransform(); + .load(card.getImage()); if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) { builder = builder.placeholder(decodeBlurHash(card.getBlurhash())); } - builder.into(cardImage); + builder.centerInside() + .into(cardImage); } else if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) { int radius = cardImage.getContext().getResources() - .getDimensionPixelSize(R.dimen.card_radius); + .getDimensionPixelSize(R.dimen.inner_card_radius); - cardView.setOrientation(LinearLayout.HORIZONTAL); + cardLayout.setOrientation(LinearLayout.HORIZONTAL); cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; cardImage.getLayoutParams().width = cardImage.getContext().getResources() .getDimensionPixelSize(R.dimen.card_image_horizontal_width); - cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT; - cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; ShapeAppearanceModel cardImageShape = ShapeAppearanceModel.builder() .setTopLeftCorner(CornerFamily.ROUNDED, radius) @@ -1242,15 +1278,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { Glide.with(cardImage.getContext()) .load(decodeBlurHash(card.getBlurhash())) - .dontTransform() .into(cardImage); } else { - cardView.setOrientation(LinearLayout.HORIZONTAL); + cardLayout.setOrientation(LinearLayout.HORIZONTAL); cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; cardImage.getLayoutParams().width = cardImage.getContext().getResources() .getDimensionPixelSize(R.dimen.card_image_horizontal_width); - cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT; - cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; cardImage.setShapeAppearanceModel(new ShapeAppearanceModel()); @@ -1265,11 +1298,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { cardView.setOnClickListener(visitLink); // View embedded photos in our image viewer instead of opening the browser - cardImage.setOnClickListener(card.getType().equals(Card.TYPE_PHOTO) && !TextUtils.isEmpty(card.getEmbedUrl()) ? + cardImage.setOnClickListener(card.getType().equals(PreviewCard.TYPE_PHOTO) && !TextUtils.isEmpty(card.getEmbedUrl()) ? v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbedUrl())) : visitLink); - - cardView.setClipToOutline(true); } else { cardView.setVisibility(View.GONE); } @@ -1296,13 +1327,4 @@ 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 bb5a78e4e..f27bc514d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -9,13 +9,11 @@ import android.text.method.LinkMovementMethod; import android.text.style.DynamicDrawableSpan; import android.text.style.ImageSpan; import android.view.View; -import android.widget.Button; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; -import androidx.appcompat.widget.ViewUtils; import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; @@ -25,11 +23,11 @@ import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.LinkHelper; import com.keylesspalace.tusky.util.NoUnderlineURLSpan; import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.util.ViewExtensionsKt; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.text.DateFormat; import java.util.Date; +import java.util.List; public class StatusDetailedViewHolder extends StatusBaseViewHolder { private final TextView reblogs; @@ -143,15 +141,16 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { public void setupWithStatus(@NonNull final StatusViewData.Concrete status, @NonNull final StatusActionListener listener, @NonNull StatusDisplayOptions statusDisplayOptions, - @Nullable Object payloads) { + @NonNull List payloads, + final boolean showStatusInfo) { // We never collapse statuses in the detail view StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ? status.copyWithCollapsed(false) : status; - super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads); + super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads, showStatusInfo); setupCard(uncollapsedStatus, status.isExpanded(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status - if (payloads == null) { + if (payloads.isEmpty()) { Status actionable = uncollapsedStatus.getActionable(); if (!statusDisplayOptions.hideStats()) { 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 327f7cfbb..8c78871f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -23,13 +23,12 @@ import android.widget.Button; import android.widget.TextView; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; 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.entity.TimelineAccount; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.NumberUtils; @@ -38,10 +37,9 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.StringUtils; import com.keylesspalace.tusky.viewdata.StatusViewData; +import java.util.Collections; import java.util.List; -import at.connyduck.sparkbutton.helpers.Utils; - 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]; @@ -63,24 +61,37 @@ public class StatusViewHolder extends StatusBaseViewHolder { public void setupWithStatus(@NonNull StatusViewData.Concrete status, @NonNull final StatusActionListener listener, @NonNull StatusDisplayOptions statusDisplayOptions, - @Nullable Object payloads) { - if (payloads == null) { - + @NonNull List payloads, + final boolean showStatusInfo) { + if (payloads.isEmpty()) { boolean sensitive = !TextUtils.isEmpty(status.getActionable().getSpoilerText()); boolean expanded = status.isExpanded(); setupCollapsedState(sensitive, expanded, status, listener); - Status reblogging = status.getRebloggingStatus(); - if (reblogging == null || status.getFilterAction() == Filter.Action.WARN) { + if (!showStatusInfo || status.getFilterAction() == Filter.Action.WARN) { hideStatusInfo(); } else { - String rebloggedByDisplayName = reblogging.getAccount().getName(); - setRebloggedByDisplayName(rebloggedByDisplayName, - reblogging.getAccount().getEmojis(), statusDisplayOptions); - statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition())); - } + Status rebloggingStatus = status.getRebloggingStatus(); + boolean isReplyOnly = rebloggingStatus == null && status.isReply(); + boolean isReplySelf = isReplyOnly && status.isSelfReply(); + boolean hasStatusInfo = rebloggingStatus != null | isReplyOnly; + + TimelineAccount statusInfoAccount = rebloggingStatus != null ? rebloggingStatus.getAccount() : status.getRepliedToAccount(); + + if (!hasStatusInfo) { + hideStatusInfo(); + } else { + setStatusInfoContent(statusInfoAccount, isReplyOnly, isReplySelf, statusDisplayOptions); + } + + if (isReplyOnly) { + statusInfo.setOnClickListener(null); + } else { + statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition())); + } + } } reblogsCountLabel.setVisibility(statusDisplayOptions.showStatsInline() ? View.VISIBLE : View.INVISIBLE); @@ -88,28 +99,38 @@ public class StatusViewHolder extends StatusBaseViewHolder { setFavouritedCount(status.getActionable().getFavouritesCount()); setReblogsCount(status.getActionable().getReblogsCount()); - super.setupWithStatus(status, listener, statusDisplayOptions, payloads); + super.setupWithStatus(status, listener, statusDisplayOptions, payloads, showStatusInfo); } - private void setRebloggedByDisplayName(final CharSequence name, - final List accountEmoji, - final StatusDisplayOptions statusDisplayOptions) { + private void setStatusInfoContent(final TimelineAccount account, + final boolean isReply, + final boolean isSelfReply, + final StatusDisplayOptions statusDisplayOptions) { Context context = statusInfo.getContext(); - CharSequence wrappedName = StringUtils.unicodeWrap(name); - CharSequence boostedText = context.getString(R.string.post_boosted_format, wrappedName); + CharSequence accountName = account != null ? account.getName() : ""; + CharSequence wrappedName = StringUtils.unicodeWrap(accountName); + CharSequence translatedText = ""; + + if (!isReply) { + translatedText = context.getString(R.string.post_boosted_format, wrappedName); + } else if (isSelfReply) { + translatedText = context.getString(R.string.post_replied_self); + } else { + if (account != null && accountName.length() > 0) { + translatedText = context.getString(R.string.post_replied_format, wrappedName); + } else { + translatedText = context.getString(R.string.post_replied); + } + } + CharSequence emojifiedText = CustomEmojiHelper.emojify( - boostedText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis() + translatedText, + account != null ? account.getEmojis() : Collections.emptyList(), + statusInfo, + statusDisplayOptions.animateEmojis() ); statusInfo.setText(emojifiedText); - statusInfo.setVisibility(View.VISIBLE); - } - - // don't use this on the same ViewHolder as setRebloggedByDisplayName, will cause recycling issues as paddings are changed - 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)); - statusInfo.setPaddingRelative(Utils.dpToPx(statusInfo.getContext(), 28), 0, 0, 0); + statusInfo.setCompoundDrawablesWithIntrinsicBounds(isReply ? R.drawable.ic_reply_18dp : R.drawable.ic_reblog_18dp, 0, 0, 0); statusInfo.setVisibility(View.VISIBLE); } @@ -125,6 +146,10 @@ public class StatusViewHolder extends StatusBaseViewHolder { statusInfo.setVisibility(View.GONE); } + protected TextView getStatusInfo() { + return statusInfo; + } + private void setupCollapsedState(boolean sensitive, boolean expanded, final StatusViewData.Concrete status, 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 627a84344..8a78cb256 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt @@ -2,6 +2,9 @@ package com.keylesspalace.tusky.appstore import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.entity.Poll +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -9,40 +12,64 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +/** + * Updates the database cache in response to events. + * This is important for the home timeline and notifications to be up to date. + */ +@OptIn(ExperimentalStdlibApi::class) class CacheUpdater @Inject constructor( eventHub: EventHub, accountManager: AccountManager, - appDatabase: AppDatabase + appDatabase: AppDatabase, + moshi: Moshi ) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - init { - val timelineDao = appDatabase.timelineDao() + private val timelineDao = appDatabase.timelineDao() + private val statusDao = appDatabase.timelineStatusDao() + private val notificationsDao = appDatabase.notificationsDao() + init { scope.launch { eventHub.events.collect { event -> - val accountId = accountManager.activeAccount?.id ?: return@collect + val tuskyAccountId = accountManager.activeAccount?.id ?: return@collect when (event) { - is StatusChangedEvent -> { - val status = event.status - timelineDao.update( - accountId = accountId, - status = status - ) + is StatusChangedEvent -> statusDao.update( + tuskyAccountId = tuskyAccountId, + status = event.status, + moshi = moshi + ) + + is UnfollowEvent -> timelineDao.removeStatusesAndReblogsByUser(tuskyAccountId, event.accountId) + + is BlockEvent -> removeAllByUser(tuskyAccountId, event.accountId) + is MuteEvent -> removeAllByUser(tuskyAccountId, event.accountId) + + is DomainMuteEvent -> { + timelineDao.deleteAllFromInstance(tuskyAccountId, event.instance) + notificationsDao.deleteAllFromInstance(tuskyAccountId, event.instance) } - is UnfollowEvent -> - timelineDao.removeAllByUser(accountId, event.accountId) - is StatusDeletedEvent -> - timelineDao.delete(accountId, event.statusId) + + is StatusDeletedEvent -> { + timelineDao.deleteAllWithStatus(tuskyAccountId, event.statusId) + notificationsDao.deleteAllWithStatus(tuskyAccountId, event.statusId) + } + is PollVoteEvent -> { - timelineDao.setVoted(accountId, event.statusId, event.poll) + val pollString = moshi.adapter().toJson(event.poll) + statusDao.setVoted(tuskyAccountId, event.statusId, pollString) } } } } } + private suspend fun removeAllByUser(tuskyAccountId: Long, accountId: String) { + timelineDao.removeAllByUser(tuskyAccountId, accountId) + notificationsDao.removeAllByUser(tuskyAccountId, accountId) + } + fun stop() { 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 cf2046359..7bc123437 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -1,23 +1,19 @@ package com.keylesspalace.tusky.appstore -import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Poll -import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.entity.Status data class StatusChangedEvent(val status: Status) : 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 scheduledStatus: ScheduledStatus) : Event +data class StatusScheduledEvent(val scheduledStatusId: String) : 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 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 88634681a..511bb26aa 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt @@ -1,14 +1,10 @@ package com.keylesspalace.tusky.appstore -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import java.util.function.Consumer import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.launch interface Event @@ -21,13 +17,4 @@ class EventHub @Inject constructor() { suspend fun dispatch(event: Event) { _events.emit(event) } - - // TODO remove as soon as NotificationsFragment is Kotlin - fun subscribe(lifecycleOwner: LifecycleOwner, consumer: Consumer) { - lifecycleOwner.lifecycleScope.launch { - events.collect { event -> - consumer.accept(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 5c46fca65..9515b9df8 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 @@ -16,13 +16,12 @@ package com.keylesspalace.tusky.components.account import android.animation.ArgbEvaluator -import android.content.ClipData -import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.res.ColorStateList import android.graphics.Color import android.graphics.Typeface +import android.os.Build import android.os.Bundle import android.text.SpannableStringBuilder import android.text.TextWatcher @@ -36,25 +35,25 @@ import androidx.activity.viewModels import androidx.annotation.ColorInt import androidx.annotation.DrawableRes import androidx.annotation.Px -import androidx.appcompat.app.AlertDialog import androidx.appcompat.content.res.AppCompatResources import androidx.core.app.ActivityOptionsCompat import androidx.core.graphics.ColorUtils 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.updateLayoutParams import androidx.core.view.updatePadding import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.lifecycleScope -import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.viewpager2.widget.MarginPageTransformer import com.bumptech.glide.Glide +import com.google.android.material.R as materialR import com.google.android.material.appbar.AppBarLayout import com.google.android.material.chip.Chip import com.google.android.material.color.MaterialColors +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel @@ -71,9 +70,8 @@ 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 -import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.DraftsAlert -import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.interfaces.AccountSelectionListener @@ -84,7 +82,9 @@ import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Success +import com.keylesspalace.tusky.util.copyToClipboard import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.ensureBottomMargin import com.keylesspalace.tusky.util.getDomain import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.loadAvatar @@ -93,16 +93,10 @@ import com.keylesspalace.tusky.util.reduceSwipeSensitivity import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation -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 dagger.hilt.android.AndroidEntryPoint import java.text.NumberFormat import java.text.ParseException import java.text.SimpleDateFormat @@ -111,25 +105,18 @@ import javax.inject.Inject import kotlin.math.abs import kotlinx.coroutines.launch -class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider, HasAndroidInjector, LinkListener { - - @Inject - lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector - - @Inject - lateinit var viewModelFactory: ViewModelFactory +@AndroidEntryPoint +class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider, LinkListener { @Inject lateinit var draftsAlert: DraftsAlert - private val viewModel: AccountViewModel by viewModels { viewModelFactory } + private val viewModel: AccountViewModel by viewModels() private val binding: ActivityAccountBinding by viewBinding(ActivityAccountBinding::inflate) private lateinit var accountFieldAdapter: AccountFieldAdapter - private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) } - private var followState: FollowState = FollowState.NOT_FOLLOWING private var blocking: Boolean = false private var muting: Boolean = false @@ -141,8 +128,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide private var animateAvatar: Boolean = false private var animateEmojis: Boolean = false - // fields for scroll animation - private var hideFab: Boolean = false + // for scroll animation private var oldOffset: Int = 0 @ColorInt @@ -180,10 +166,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide // Obtain information to fill out the profile. viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!) - val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this) - animateAvatar = sharedPrefs.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) - animateEmojis = sharedPrefs.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) - hideFab = sharedPrefs.getBoolean(PrefKeys.FAB_HIDE, false) + animateAvatar = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) handleWindowInsets() setupToolbar() @@ -204,9 +188,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide * Load colors and dimensions from resources */ private fun loadResources() { - toolbarColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, Color.BLACK) + toolbarColor = MaterialColors.getColor(binding.accountToolbar, materialR.attr.colorSurface) statusBarColorTransparent = getColor(R.color.transparent_statusbar_background) - statusBarColorOpaque = MaterialColors.getColor(this, androidx.appcompat.R.attr.colorPrimaryDark, Color.BLACK) + statusBarColorOpaque = MaterialColors.getColor(binding.accountToolbar, materialR.attr.colorPrimaryDark) avatarSize = resources.getDimension(R.dimen.account_activity_avatar_size) titleVisibleHeight = resources.getDimensionPixelSize(R.dimen.account_activity_scroll_title_visible_height) } @@ -248,7 +232,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide } // If wellbeing mode is enabled, follow stats and posts count should be hidden - val preferences = PreferenceManager.getDefaultSharedPreferences(this) val wellbeingEnabled = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_PROFILE, false) if (wellbeingEnabled) { @@ -304,25 +287,21 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide } private fun handleWindowInsets() { + binding.accountFloatingActionButton.ensureBottomMargin() ViewCompat.setOnApplyWindowInsetsListener(binding.accountCoordinatorLayout) { _, insets -> - val top = insets.getInsets(systemBars()).top - val toolbarParams = binding.accountToolbar.layoutParams as ViewGroup.MarginLayoutParams - toolbarParams.topMargin = top + val systemBarInsets = insets.getInsets(systemBars()) + val top = systemBarInsets.top + + binding.accountToolbar.updateLayoutParams { + topMargin = top + } - val right = insets.getInsets(systemBars()).right - val bottom = insets.getInsets(systemBars()).bottom - val left = insets.getInsets(systemBars()).left - binding.accountCoordinatorLayout.updatePadding( - right = right, - bottom = bottom, - left = left - ) binding.swipeToRefreshLayout.setProgressViewEndTarget( false, top + resources.getDimensionPixelSize(R.dimen.account_swiperefresh_distance) ) - WindowInsetsCompat.CONSUMED + insets.inset(0, top, 0, 0) } } @@ -335,28 +314,13 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide setDisplayShowTitleEnabled(false) } - val appBarElevation = resources.getDimension(R.dimen.actionbar_elevation) - - val toolbarBackground = MaterialShapeDrawable.createWithElevationOverlay( - this, - appBarElevation - ) - toolbarBackground.fillColor = ColorStateList.valueOf(Color.TRANSPARENT) - binding.accountToolbar.background = toolbarBackground + binding.accountToolbar.setBackgroundColor(Color.TRANSPARENT) binding.accountToolbar.setNavigationIcon(R.drawable.ic_arrow_back_with_background) - binding.accountToolbar.setOverflowIcon( - AppCompatResources.getDrawable(this, R.drawable.ic_more_with_background) - ) + binding.accountToolbar.overflowIcon = AppCompatResources.getDrawable(this, R.drawable.ic_more_with_background) - binding.accountHeaderInfoContainer.background = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation) - - val avatarBackground = MaterialShapeDrawable.createWithElevationOverlay( - this, - appBarElevation - ).apply { + val avatarBackground = MaterialShapeDrawable().apply { fillColor = ColorStateList.valueOf(toolbarColor) - elevation = appBarElevation shapeAppearanceModel = ShapeAppearanceModel.builder() .setAllCornerSizes(resources.getDimension(R.dimen.account_avatar_background_radius)) .build() @@ -378,15 +342,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide supportActionBar?.setDisplayShowTitleEnabled(false) } - if (hideFab && !blocking) { - if (verticalOffset > oldOffset) { - binding.accountFloatingActionButton.show() - } - if (verticalOffset < oldOffset) { - binding.accountFloatingActionButton.hide() - } - } - val scaledAvatarSize = (avatarSize + verticalOffset) / avatarSize binding.accountAvatarImageView.scaleX = scaledAvatarSize @@ -398,7 +353,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide 1f ) - window.statusBarColor = argbEvaluator.evaluate(transparencyPercent, statusBarColorTransparent, statusBarColorOpaque) as Int + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) { + @Suppress("DEPRECATION") + window.statusBarColor = argbEvaluator.evaluate(transparencyPercent, statusBarColorTransparent, statusBarColorOpaque) as Int + } val evaluatedToolbarColor = argbEvaluator.evaluate( transparencyPercent, @@ -406,7 +364,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide toolbarColor ) as Int - toolbarBackground.fillColor = ColorStateList.valueOf(evaluatedToolbarColor) + binding.accountToolbar.setBackgroundColor(evaluatedToolbarColor) + binding.accountStatusBarScrim.setBackgroundColor(evaluatedToolbarColor) binding.swipeToRefreshLayout.isEnabled = verticalOffset == 0 } @@ -415,7 +374,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide private fun makeNotificationBarTransparent() { WindowCompat.setDecorFitsSystemWindows(window, false) - window.statusBarColor = statusBarColorTransparent + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) { + @Suppress("DEPRECATION") + window.statusBarColor = statusBarColorTransparent + } } /** @@ -426,7 +388,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide viewModel.accountData.collect { if (it == null) return@collect when (it) { - is Success -> onAccountChanged(it.data) + is Success -> { + onAccountChanged(it.data) + binding.swipeToRefreshLayout.isEnabled = true + } is Error -> { Snackbar.make( binding.accountCoordinatorLayout, @@ -435,6 +400,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide ) .setAction(R.string.action_retry) { viewModel.refresh() } .show() + binding.swipeToRefreshLayout.isEnabled = true } is Loading -> { } } @@ -477,13 +443,13 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide * Setup swipe to refresh layout */ private fun setupRefreshLayout() { + binding.swipeToRefreshLayout.isEnabled = false // will only be enabled after the first load completed binding.swipeToRefreshLayout.setOnRefreshListener { onRefresh() } lifecycleScope.launch { - viewModel.isRefreshing.collect { isRefreshing -> - binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true + viewModel.isRefreshing.collect { + binding.swipeToRefreshLayout.isRefreshing = it } } - binding.swipeToRefreshLayout.setColorSchemeResources(R.color.chinwag_green) } private fun onAccountChanged(account: Account?) { @@ -497,15 +463,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide for (view in listOf(binding.accountUsernameTextView, binding.accountDisplayNameTextView)) { view.setOnLongClickListener { loadedAccount?.let { loadedAccount -> - val fullUsername = getFullUsername(loadedAccount) - val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText(null, fullUsername)) - Snackbar.make( - binding.root, + copyToClipboard( + getFullUsername(loadedAccount), getString(R.string.account_username_copied), - Snackbar.LENGTH_SHORT ) - .show() } true } @@ -682,6 +643,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide binding.accountFloatingActionButton.setOnClickListener { mention() } binding.accountFollowButton.setOnClickListener { + val confirmFollows = preferences.getBoolean(PrefKeys.CONFIRM_FOLLOWS, false) if (viewModel.isSelf) { val intent = Intent(this@AccountActivity, EditProfileActivity::class.java) startActivity(intent) @@ -695,7 +657,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide when (followState) { FollowState.NOT_FOLLOWING -> { - viewModel.changeFollowState() + if (confirmFollows) { + showFollowWarningDialog() + } else { + viewModel.changeFollowState() + } } FollowState.REQUESTED -> { showFollowRequestPendingDialog() @@ -721,11 +687,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide blockingDomain = relation.blockingDomain showingReblogs = relation.showingReblogs - // If wellbeing mode is enabled, "follows you" text should not be visible - val preferences = PreferenceManager.getDefaultSharedPreferences(this) - val wellbeingEnabled = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_PROFILE, false) - - binding.accountFollowsYouTextView.visible(relation.followedBy && !wellbeingEnabled) + binding.accountFollowsYouTextView.visible(relation.followedBy) // because subscribing is Pleroma extension, enable it __only__ when we have non-null subscribing field // it's also now supported in Mastodon 3.3.0rc but called notifying and use different API call @@ -890,17 +852,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide if (!viewModel.isSelf && followState != FollowState.FOLLOWING) { menu.removeItem(R.id.action_add_or_remove_from_list) } - - 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() { - AlertDialog.Builder(this) + MaterialAlertDialogBuilder(this) .setMessage(R.string.dialog_message_cancel_follow_request) .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() } .setNegativeButton(android.R.string.cancel, null) @@ -908,18 +863,26 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide } private fun showUnfollowWarningDialog() { - AlertDialog.Builder(this) + MaterialAlertDialogBuilder(this) .setMessage(R.string.dialog_unfollow_warning) .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() } .setNegativeButton(android.R.string.cancel, null) .show() } + private fun showFollowWarningDialog() { + MaterialAlertDialogBuilder(this) + .setMessage(R.string.dialog_follow_warning) + .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + private fun toggleBlockDomain(instance: String) { if (blockingDomain) { viewModel.unblockDomain(instance) } else { - AlertDialog.Builder(this) + MaterialAlertDialogBuilder(this) .setMessage(getString(R.string.mute_domain_warning, instance)) .setPositiveButton( getString(R.string.mute_domain_warning_dialog_ok) @@ -931,7 +894,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide private fun toggleBlock() { if (viewModel.relationshipData.value?.data?.blocking != true) { - AlertDialog.Builder(this) + MaterialAlertDialogBuilder(this) .setMessage(getString(R.string.dialog_block_warning, loadedAccount?.username)) .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeBlockState() } .setNegativeButton(android.R.string.cancel, null) @@ -1133,6 +1096,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide badge.isClickable = false badge.isFocusable = false badge.setEnsureMinTouchTargetSize(false) + badge.isCloseIconVisible = false // reset some chip defaults so it looks better for our badge usecase badge.iconStartPadding = resources.getDimension(R.dimen.profile_badge_icon_start_padding) @@ -1143,8 +1107,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide return badge } - override fun androidInjector() = dispatchingAndroidInjector - companion object { private const val KEY_ACCOUNT_ID = "id" 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 91d6dd0ea..c9626da8b 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 @@ -19,18 +19,16 @@ import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.getDomain +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +@HiltViewModel class AccountViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub, @@ -46,10 +44,8 @@ class AccountViewModel @Inject constructor( private val _noteSaved = MutableStateFlow(false) val noteSaved: StateFlow = _noteSaved.asStateFlow() - private val _isRefreshing = MutableSharedFlow(1, onBufferOverflow = BufferOverflow.DROP_OLDEST) - val isRefreshing: SharedFlow = _isRefreshing.asSharedFlow() - - private var isDataLoading = false + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() lateinit var accountId: String var isSelf = false @@ -76,7 +72,9 @@ class AccountViewModel @Inject constructor( private fun obtainAccount(reload: Boolean = false) { if (_accountData.value == null || reload) { - isDataLoading = true + if (reload) { + _isRefreshing.value = true + } _accountData.value = Loading() viewModelScope.launch { @@ -87,14 +85,12 @@ class AccountViewModel @Inject constructor( isFromOwnDomain = domain == activeAccount.domain _accountData.value = Success(account) - isDataLoading = false - _isRefreshing.emit(false) + _isRefreshing.value = false }, { t -> Log.w(TAG, "failed obtaining account", t) _accountData.value = Error(cause = t) - isDataLoading = false - _isRefreshing.emit(false) + _isRefreshing.value = false } ) } @@ -316,7 +312,7 @@ class AccountViewModel @Inject constructor( } private fun reload(isReload: Boolean = false) { - if (isDataLoading) { + if (_isRefreshing.value) { return } accountId.let { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListSelectionFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListSelectionFragment.kt index 585c7e311..bfa15b737 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListSelectionFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListSelectionFragment.kt @@ -24,48 +24,37 @@ import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.ListsActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.FragmentListsListBinding import com.keylesspalace.tusky.databinding.ItemListBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.visible -import javax.inject.Inject +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -class ListSelectionFragment : DialogFragment(), Injectable { +@AndroidEntryPoint +class ListSelectionFragment : DialogFragment() { interface ListSelectionListener { fun onListSelected(list: MastoList) } - @Inject - lateinit var viewModelFactory: ViewModelFactory - - private val viewModel: ListsForAccountViewModel by viewModels { viewModelFactory } - - private var _binding: FragmentListsListBinding? = null - - // This property is only valid between onCreateDialog and onDestroyView - private val binding get() = _binding!! - - private val adapter = Adapter() + private val viewModel: ListsForAccountViewModel by viewModels() private var selectListener: ListSelectionListener? = null private var accountId: String? = null @@ -77,17 +66,17 @@ class ListSelectionFragment : DialogFragment(), Injectable { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle) accountId = requireArguments().getString(ARG_ACCOUNT_ID) } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val context = requireContext() - _binding = FragmentListsListBinding.inflate(layoutInflater) + val binding = FragmentListsListBinding.inflate(layoutInflater) + val adapter = Adapter() binding.listsView.adapter = adapter - val dialogBuilder = AlertDialog.Builder(context) + val dialogBuilder = MaterialAlertDialogBuilder(context) .setView(binding.root) .setTitle(R.string.select_list_title) .setNeutralButton(R.string.select_list_manage) { _, _ -> @@ -124,7 +113,7 @@ class ListSelectionFragment : DialogFragment(), Injectable { binding.listsView.hide() binding.messageView.apply { show() - setup(error) { load() } + setup(error) { load(binding) } } } } @@ -159,7 +148,7 @@ class ListSelectionFragment : DialogFragment(), Injectable { } lifecycleScope.launch { - load() + load(binding) } return dialog @@ -177,12 +166,7 @@ class ListSelectionFragment : DialogFragment(), Injectable { } } - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - private fun load() { + private fun load(binding: FragmentListsListBinding) { binding.progressBar.show() binding.listsView.hide() binding.messageView.hide() 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 b0546eceb..692aa2d38 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 @@ -24,6 +24,7 @@ import at.connyduck.calladapter.networkresult.onSuccess import at.connyduck.calladapter.networkresult.runCatching import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.network.MastodonApi +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow @@ -48,6 +49,7 @@ data class ActionError( } } +@HiltViewModel @OptIn(ExperimentalCoroutinesApi::class) class ListsForAccountViewModel @Inject constructor( private val mastodonApi: MastodonApi 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 d0f534a9e..2c76cdeab 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 @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.components.account.media +import android.content.SharedPreferences import android.os.Bundle import android.view.Menu import android.view.MenuInflater @@ -28,15 +29,12 @@ 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 import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.settings.PrefKeys @@ -49,6 +47,7 @@ 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.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -56,23 +55,23 @@ import kotlinx.coroutines.launch /** * Fragment with multiple columns of media previews for the specified account. */ +@AndroidEntryPoint class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFragment, - MenuProvider, - Injectable { - - @Inject - lateinit var viewModelFactory: ViewModelFactory + MenuProvider { @Inject lateinit var accountManager: AccountManager + @Inject + lateinit var preferences: SharedPreferences + private val binding by viewBinding(FragmentTimelineBinding::bind) - private val viewModel: AccountMediaViewModel by viewModels { viewModelFactory } + private val viewModel: AccountMediaViewModel by viewModels() - private lateinit var adapter: AccountMediaGridAdapter + private var adapter: AccountMediaGridAdapter? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -82,14 +81,14 @@ class AccountMediaFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true) - adapter = AccountMediaGridAdapter( + val adapter = AccountMediaGridAdapter( useBlurhash = useBlurhash, context = view.context, onAttachmentClickListener = ::onAttachmentClick ) + this.adapter = adapter val columnCount = view.context.resources.getInteger(R.integer.profile_media_column_count) val imageSpacing = view.context.resources.getDimensionPixelSize( @@ -147,6 +146,12 @@ class AccountMediaFragment : } } + override fun onDestroyView() { + // Clear the adapter to prevent leaking the View + adapter = null + super.onDestroyView() + } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.fragment_account_media, menu) menu.findItem(R.id.action_refresh)?.apply { @@ -202,13 +207,13 @@ class AccountMediaFragment : } } Attachment.Type.UNKNOWN -> { - context?.openLink(selected.attachment.url) + context?.openLink(selected.attachment.unknownUrl) } } } override fun refreshContent() { - adapter.refresh() + adapter?.refresh() } companion object { 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 c392927d3..6fc33f23a 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 @@ -5,18 +5,19 @@ import android.graphics.Color import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast import androidx.appcompat.content.res.AppCompatResources +import androidx.appcompat.widget.TooltipCompat import androidx.core.view.setPadding import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import com.bumptech.glide.Glide +import com.google.android.material.R as materialR import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAccountMediaBinding import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.util.BindingHolder -import com.keylesspalace.tusky.util.decodeBlurHash +import com.keylesspalace.tusky.util.BlurhashDrawable import com.keylesspalace.tusky.util.getFormattedDescription import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show @@ -47,7 +48,7 @@ class AccountMediaGridAdapter( private val baseItemBackgroundColor = MaterialColors.getColor( context, - com.google.android.material.R.attr.colorSurface, + materialR.attr.colorSurface, Color.BLACK ) private val videoIndicator = AppCompatResources.getDrawable( @@ -85,7 +86,7 @@ class AccountMediaGridAdapter( val blurhash = item.attachment.blurhash val placeholder = if (useBlurhash && blurhash != null) { - decodeBlurHash(context, blurhash) + BlurhashDrawable(context, blurhash) } else { null } @@ -141,11 +142,7 @@ class AccountMediaGridAdapter( onAttachmentClickListener(item, imageView) } - holder.binding.root.setOnLongClickListener { view -> - val description = item.attachment.getFormattedDescription(view.context) - Toast.makeText(view.context, description, Toast.LENGTH_LONG).show() - true - } + TooltipCompat.setTooltipText(holder.binding.root, imageView.contentDescription) } } } 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 77fb1dfb1..dd8dae73c 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 @@ -20,7 +20,7 @@ import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import com.keylesspalace.tusky.components.timeline.util.ifExpected -import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.viewdata.AttachmentViewData import retrofit2.HttpException @@ -59,7 +59,15 @@ class AccountMediaRemoteMediator( } val attachments = statuses.flatMap { status -> - AttachmentViewData.list(status, activeAccount.alwaysShowSensitiveMedia) + status.attachments.map { attachment -> + AttachmentViewData( + attachment = attachment, + statusId = status.id, + statusUrl = status.url.orEmpty(), + sensitive = status.sensitive, + isRevealed = activeAccount.alwaysShowSensitiveMedia || !status.sensitive + ) + } } if (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 b42a7282d..0f9077286 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 @@ -24,8 +24,10 @@ import androidx.paging.cachedIn import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.viewdata.AttachmentViewData +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +@HiltViewModel class AccountMediaViewModel @Inject constructor( accountManager: AccountManager, api: MastodonApi diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt index 2419dc915..b8d972c43 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt @@ -22,14 +22,11 @@ import androidx.fragment.app.commit import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ActivityAccountListBinding -import dagger.android.DispatchingAndroidInjector -import dagger.android.HasAndroidInjector -import javax.inject.Inject +import com.keylesspalace.tusky.util.getSerializableExtraCompat +import dagger.hilt.android.AndroidEntryPoint -class AccountListActivity : BottomSheetActivity(), HasAndroidInjector { - - @Inject - lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector +@AndroidEntryPoint +class AccountListActivity : BottomSheetActivity() { enum class Type { FOLLOWS, @@ -46,7 +43,7 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector { val binding = ActivityAccountListBinding.inflate(layoutInflater) setContentView(binding.root) - val type = intent.getSerializableExtra(EXTRA_TYPE) as Type + val type = intent.getSerializableExtraCompat(EXTRA_TYPE)!! val id: String? = intent.getStringExtra(EXTRA_ID) setSupportActionBar(binding.includedToolbar.toolbar) @@ -69,8 +66,6 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector { } } - override fun androidInjector() = dispatchingAndroidInjector - companion object { private const val EXTRA_TYPE = "type" private const val EXTRA_ID = "id" diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt index 48ac0c021..e43b87d45 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt @@ -15,12 +15,12 @@ package com.keylesspalace.tusky.components.accountlist +import android.content.SharedPreferences import android.os.Bundle import android.util.Log import android.view.View import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope -import androidx.preference.PreferenceManager import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager @@ -42,7 +42,6 @@ import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsHead 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 @@ -50,21 +49,24 @@ import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.HttpHeaderLink +import com.keylesspalace.tusky.util.ensureBottomPadding +import com.keylesspalace.tusky.util.getSerializableCompat import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.view.EndlessOnScrollListener +import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import retrofit2.Response +@AndroidEntryPoint class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountActionListener, - LinkListener, - Injectable { + LinkListener { @Inject lateinit var api: MastodonApi @@ -72,23 +74,26 @@ class AccountListFragment : @Inject lateinit var accountManager: AccountManager + @Inject + lateinit var preferences: SharedPreferences + private val binding by viewBinding(FragmentAccountListBinding::bind) private lateinit var type: Type private var id: String? = null - private lateinit var scrollListener: EndlessOnScrollListener - private lateinit var adapter: AccountAdapter<*> + private var adapter: AccountAdapter<*>? = null private var fetching = false private var bottomId: String? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - type = requireArguments().getSerializable(ARG_TYPE) as Type + type = requireArguments().getSerializableCompat(ARG_TYPE)!! id = requireArguments().getString(ARG_ID) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.recyclerView.ensureBottomPadding() binding.recyclerView.setHasFixedSize(true) val layoutManager = LinearLayoutManager(view.context) binding.recyclerView.layoutManager = layoutManager @@ -97,17 +102,13 @@ class AccountListFragment : DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL) ) - binding.swipeRefreshLayout.setOnRefreshListener { fetchAccounts() } - binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) - - val pm = PreferenceManager.getDefaultSharedPreferences(view.context) - val animateAvatar = pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) - val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) - val showBotOverlay = pm.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true) + val animateAvatar = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) + val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + val showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true) val activeAccount = accountManager.activeAccount!! - adapter = when (type) { + val adapter = when (type) { Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay) Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay) Type.FOLLOW_REQUESTS -> { @@ -122,22 +123,31 @@ class AccountListFragment : } else -> FollowAdapter(this, animateAvatar, animateEmojis, showBotOverlay) } + this.adapter = adapter if (binding.recyclerView.adapter == null) { binding.recyclerView.adapter = adapter } - scrollListener = object : EndlessOnScrollListener(layoutManager) { + val scrollListener = object : EndlessOnScrollListener(layoutManager) { override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { if (bottomId == null) { return } - fetchAccounts(bottomId) + fetchAccounts(adapter, bottomId) } } binding.recyclerView.addOnScrollListener(scrollListener) - fetchAccounts() + binding.swipeRefreshLayout.setOnRefreshListener { fetchAccounts(adapter) } + + fetchAccounts(adapter) + } + + override fun onDestroyView() { + // Clear the adapter to prevent leaking the View + adapter = null + super.onDestroyView() } override fun onViewTag(tag: String) { @@ -245,12 +255,12 @@ class AccountListFragment : Log.e(TAG, "Failed to $verb account accountId $accountId") } - override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) { + override fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) { viewLifecycleOwner.lifecycleScope.launch { if (accept) { - api.authorizeFollowRequest(accountId) + api.authorizeFollowRequest(id) } else { - api.rejectFollowRequest(accountId) + api.rejectFollowRequest(id) }.fold( onSuccess = { onRespondToFollowRequestSuccess(position) @@ -261,7 +271,7 @@ class AccountListFragment : } else { "reject" } - Log.e(TAG, "Failed to $verb account id $accountId.", throwable) + Log.e(TAG, "Failed to $verb account id $id.", throwable) } ) } @@ -300,7 +310,7 @@ class AccountListFragment : return requireNotNull(id) { "id must not be null for type " + type.name } } - private fun fetchAccounts(fromId: String? = null) { + private fun fetchAccounts(adapter: AccountAdapter<*>, fromId: String? = null) { if (fetching) { return } @@ -316,19 +326,19 @@ class AccountListFragment : val response = getFetchCallByListType(fromId) if (!response.isSuccessful) { - onFetchAccountsFailure(Exception(response.message())) + onFetchAccountsFailure(adapter, Exception(response.message())) return@launch } val accountList = response.body() if (accountList == null) { - onFetchAccountsFailure(Exception(response.message())) + onFetchAccountsFailure(adapter, Exception(response.message())) return@launch } val linkHeader = response.headers()["Link"] - onFetchAccountsSuccess(accountList, linkHeader) + onFetchAccountsSuccess(adapter, accountList, linkHeader) } catch (exception: Exception) { if (exception is CancellationException) { // Scope is cancelled, probably because the fragment is destroyed. @@ -336,12 +346,16 @@ class AccountListFragment : // (CancellationException in a cancelled scope is normal and will be ignored) throw exception } - onFetchAccountsFailure(exception) + onFetchAccountsFailure(adapter, exception) } } } - private fun onFetchAccountsSuccess(accounts: List, linkHeader: String?) { + private fun onFetchAccountsSuccess( + adapter: AccountAdapter<*>, + accounts: List, + linkHeader: String? + ) { adapter.setBottomLoading(false) binding.swipeRefreshLayout.isRefreshing = false @@ -356,7 +370,7 @@ class AccountListFragment : } if (adapter is MutesAdapter) { - fetchRelationships(accounts.map { it.id }) + fetchRelationships(adapter, accounts.map { it.id }) } bottomId = fromId @@ -375,23 +389,30 @@ class AccountListFragment : } } - private fun fetchRelationships(ids: List) { - lifecycleScope.launch { + private fun fetchRelationships(mutesAdapter: MutesAdapter, ids: List) { + viewLifecycleOwner.lifecycleScope.launch { api.relationships(ids) - .fold(::onFetchRelationshipsSuccess) { throwable -> - Log.e(TAG, "Fetch failure for relationships of accounts: $ids", throwable) - } + .fold( + onSuccess = { relationships -> + onFetchRelationshipsSuccess(mutesAdapter, relationships) + }, + onFailure = { throwable -> + Log.e(TAG, "Fetch failure for relationships of accounts: $ids", throwable) + } + ) } } - private fun onFetchRelationshipsSuccess(relationships: List) { - val mutesAdapter = adapter as MutesAdapter + private fun onFetchRelationshipsSuccess( + mutesAdapter: MutesAdapter, + relationships: List + ) { val mutingNotificationsMap = HashMap() relationships.map { mutingNotificationsMap.put(it.id, it.mutingNotifications) } mutesAdapter.updateMutingNotificationsMap(mutingNotificationsMap) } - private fun onFetchAccountsFailure(throwable: Throwable) { + private fun onFetchAccountsFailure(adapter: AccountAdapter<*>, throwable: Throwable) { fetching = false binding.swipeRefreshLayout.isRefreshing = false Log.e(TAG, "Fetch failure", throwable) @@ -400,7 +421,7 @@ class AccountListFragment : binding.messageView.show() binding.messageView.setup(throwable) { binding.messageView.hide() - this.fetchAccounts(null) + this.fetchAccounts(adapter, null) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt index 1a9a7513c..ac327ac03 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt @@ -21,7 +21,7 @@ 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 +import com.keylesspalace.tusky.util.removeDuplicatesTo /** Generic adapter with bottom loading indicator. */ abstract class AccountAdapter internal constructor( @@ -74,7 +74,7 @@ abstract class AccountAdapter internal constructo } fun update(newAccounts: List) { - accountList = removeDuplicates(newAccounts) + accountList = newAccounts.removeDuplicatesTo(ArrayList()) notifyDataSetChanged() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt index cdce38142..fc860e59e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt @@ -44,6 +44,7 @@ class FollowRequestsAdapter( ) return FollowRequestViewHolder( binding, + accountActionListener, linkListener, showHeader = false ) 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 1ce538927..68d4e98bc 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 @@ -16,15 +16,16 @@ package com.keylesspalace.tusky.components.announcements import android.annotation.SuppressLint +import android.graphics.drawable.Drawable import android.os.Build -import android.text.SpannableStringBuilder -import android.view.ContextThemeWrapper +import android.text.SpannableString import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.size import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.Target import com.google.android.material.chip.Chip import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAnnouncementBinding @@ -33,9 +34,11 @@ import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.AbsoluteTimeFormatter import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.EmojiSpan +import com.keylesspalace.tusky.util.clearEmojiTargets import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.setClickableText +import com.keylesspalace.tusky.util.setEmojiTargets import com.keylesspalace.tusky.util.visible interface AnnouncementActionListener : LinkListener { @@ -94,12 +97,19 @@ class AnnouncementAdapter( // hide button if announcement badge limit is already reached addReactionChip.visible(item.reactions.size < 8) + val requestManager = Glide.with(chips) + + chips.clearEmojiTargets() + val targets = ArrayList>(item.reactions.size) + item.reactions.forEachIndexed { i, reaction -> ( chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip? - ?: Chip(ContextThemeWrapper(chips.context, com.google.android.material.R.style.Widget_MaterialComponents_Chip_Choice)).apply { + ?: Chip(chips.context).apply { isCheckable = true checkedIcon = null + isCloseIconVisible = false + setChipBackgroundColorResource(R.color.selectable_chip_background) chips.addView(this, i) } ) @@ -109,13 +119,14 @@ class AnnouncementAdapter( } else { // we set the EmojiSpan on a space, because otherwise the Chip won't have the right size // https://github.com/tuskyapp/Tusky/issues/2308 - val spanBuilder = SpannableStringBuilder(" ${reaction.count}") + val spannable = SpannableString(" ${reaction.count}") val span = EmojiSpan(this) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { span.contentDescription = reaction.name } - spanBuilder.setSpan(span, 0, 1, 0) - Glide.with(this) + val target = span.createGlideTarget(this, animateEmojis) + spannable.setSpan(span, 0, 1, 0) + requestManager .asDrawable() .load( if (animateEmojis) { @@ -124,8 +135,9 @@ class AnnouncementAdapter( reaction.staticUrl } ) - .into(span.getTarget(animateEmojis)) - this.text = spanBuilder + .into(target) + targets.add(target) + this.text = spannable } isChecked = reaction.me @@ -144,11 +156,18 @@ class AnnouncementAdapter( chips.removeViewAt(item.reactions.size) } + // Store Glide targets for later cancellation + chips.setEmojiTargets(targets) + addReactionChip.setOnClickListener { listener.openReactionPicker(item.id, it) } } + override fun onViewRecycled(holder: BindingHolder) { + holder.binding.chipGroup.clearEmojiTargets() + } + override fun getItemCount() = items.size fun updateList(items: List) { 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 80e6d7af4..eafbdb5fa 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 @@ -17,7 +17,6 @@ package com.keylesspalace.tusky.components.announcements import android.content.Context import android.content.Intent -import android.content.SharedPreferences import android.os.Bundle import android.view.Menu import android.view.MenuInflater @@ -27,46 +26,36 @@ import android.widget.PopupWindow import androidx.activity.viewModels import androidx.core.view.MenuProvider import androidx.lifecycle.lifecycleScope -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 import com.keylesspalace.tusky.adapter.EmojiAdapter import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener import com.keylesspalace.tusky.databinding.ActivityAnnouncementsBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Success +import com.keylesspalace.tusky.util.ensureBottomPadding import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation 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 +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +@AndroidEntryPoint class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, OnEmojiSelectedListener, - MenuProvider, - Injectable { + MenuProvider { - @Inject - lateinit var viewModelFactory: ViewModelFactory - - private val viewModel: AnnouncementsViewModel by viewModels { viewModelFactory } + private val viewModel: AnnouncementsViewModel by viewModels() private val binding by viewBinding(ActivityAnnouncementsBinding::inflate) @@ -98,14 +87,13 @@ class AnnouncementsActivity : } binding.swipeRefreshLayout.setOnRefreshListener(this::refreshAnnouncements) - binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) + binding.announcementsList.ensureBottomPadding() binding.announcementsList.setHasFixedSize(true) binding.announcementsList.layoutManager = LinearLayoutManager(this) val divider = DividerItemDecoration(this, DividerItemDecoration.VERTICAL) binding.announcementsList.addItemDecoration(divider) - val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) val wellbeingEnabled = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false) val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) @@ -161,12 +149,6 @@ class AnnouncementsActivity : 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 { 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 1e1503c6e..665ee3ce6 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 @@ -29,12 +29,14 @@ import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Success +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +@HiltViewModel class AnnouncementsViewModel @Inject constructor( private val instanceInfoRepo: InstanceInfoRepository, private val mastodonApi: MastodonApi, @@ -56,7 +58,7 @@ class AnnouncementsViewModel @Inject constructor( fun load() { viewModelScope.launch { _announcements.value = Loading() - mastodonApi.listAnnouncements() + mastodonApi.announcements() .fold( { _announcements.value = Success(it) 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 de354eda3..45ab7c84a 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 @@ -16,15 +16,11 @@ package com.keylesspalace.tusky.components.compose import android.Manifest -import android.app.ProgressDialog import android.content.ClipData import android.content.Context import android.content.Intent import android.content.SharedPreferences -import android.content.pm.PackageManager import android.graphics.Bitmap -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter import android.icu.text.BreakIterator import android.net.Uri import android.os.Build @@ -46,32 +42,33 @@ import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels +import androidx.annotation.AttrRes import androidx.annotation.ColorInt import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting -import androidx.appcompat.app.AlertDialog -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat import androidx.core.content.FileProvider -import androidx.core.content.IntentCompat import androidx.core.content.res.use -import androidx.core.os.BundleCompat import androidx.core.view.ContentInfoCompat import androidx.core.view.OnReceiveContentListener +import androidx.core.view.WindowInsetsCompat.Type.ime +import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding import androidx.core.widget.doAfterTextChanged import androidx.core.widget.doOnTextChanged import androidx.lifecycle.lifecycleScope -import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.transition.TransitionManager import com.canhub.cropper.CropImage import com.canhub.cropper.CropImageContract import com.canhub.cropper.options +import com.google.android.material.R as materialR import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback import com.google.android.material.color.MaterialColors +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BuildConfig @@ -80,6 +77,7 @@ import com.keylesspalace.tusky.adapter.EmojiAdapter import com.keylesspalace.tusky.adapter.LocaleAdapter import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener import com.keylesspalace.tusky.components.compose.ComposeViewModel.ConfirmationKind +import com.keylesspalace.tusky.components.compose.ComposeViewModel.QueuedMedia import com.keylesspalace.tusky.components.compose.dialog.CaptionDialog import com.keylesspalace.tusky.components.compose.dialog.makeFocusDialog import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog @@ -87,10 +85,8 @@ import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.databinding.ActivityComposeBinding -import com.keylesspalace.tusky.db.AccountEntity -import com.keylesspalace.tusky.db.DraftAttachment -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.db.entity.AccountEntity +import com.keylesspalace.tusky.db.entity.DraftAttachment import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.NewPoll @@ -100,27 +96,34 @@ import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME import com.keylesspalace.tusky.util.MentionSpan import com.keylesspalace.tusky.util.PickMediaFiles +import com.keylesspalace.tusky.util.defaultFinders import com.keylesspalace.tusky.util.getInitialLanguages import com.keylesspalace.tusky.util.getLocaleList import com.keylesspalace.tusky.util.getMediaSize +import com.keylesspalace.tusky.util.getParcelableArrayListExtraCompat +import com.keylesspalace.tusky.util.getParcelableCompat +import com.keylesspalace.tusky.util.getParcelableExtraCompat +import com.keylesspalace.tusky.util.getSerializableCompat import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.highlightSpans import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.map import com.keylesspalace.tusky.util.modernLanguageCode import com.keylesspalace.tusky.util.setDrawableTint +import com.keylesspalace.tusky.util.setOnWindowInsetsChangeListener 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 import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.migration.OptionalInject import java.io.File import java.io.IOException import java.text.DecimalFormat import java.util.Locale -import javax.inject.Inject import kotlin.math.max import kotlin.math.min import kotlinx.coroutines.flow.collect @@ -129,19 +132,17 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize +@OptionalInject +@AndroidEntryPoint class ComposeActivity : BaseActivity(), ComposeOptionsListener, ComposeAutoCompleteAdapter.AutocompletionProvider, OnEmojiSelectedListener, - Injectable, OnReceiveContentListener, ComposeScheduleView.OnTimeSetListener, CaptionDialog.Listener { - @Inject - lateinit var viewModelFactory: ViewModelFactory - private lateinit var composeOptionsBehavior: BottomSheetBehavior<*> private lateinit var addMediaBehavior: BottomSheetBehavior<*> private lateinit var emojiBehavior: BottomSheetBehavior<*> @@ -152,25 +153,43 @@ class ComposeActivity : private var photoUploadUri: Uri? = null - private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) } + @VisibleForTesting + var highlightFinders = defaultFinders @VisibleForTesting var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT var charactersReservedPerUrl = InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL - private val viewModel: ComposeViewModel by viewModels { viewModelFactory } + private val viewModel: ComposeViewModel by viewModels() private val binding by viewBinding(ActivityComposeBinding::inflate) private var maxUploadMediaNumber = InstanceInfoRepository.DEFAULT_MAX_MEDIA_ATTACHMENTS - private val takePicture = + private val takePictureLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { success -> if (success) { - pickMedia(photoUploadUri!!) + viewModel.pickMedia(photoUploadUri!!) } } - private val pickMediaFile = registerForActivityResult(PickMediaFiles()) { uris -> + private val pickMediaFilePermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) { + pickMediaFileLauncher.launch(true) + } else { + Snackbar.make( + binding.activityCompose, + R.string.error_media_upload_permission, + Snackbar.LENGTH_SHORT + ).apply { + setAction(R.string.action_retry) { onMediaPick() } + // necessary so snackbar is shown over everything + view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) + show() + } + } + } + private val pickMediaFileLauncher = registerForActivityResult(PickMediaFiles()) { uris -> if (viewModel.media.value.size + uris.size > maxUploadMediaNumber) { Toast.makeText( this, @@ -182,9 +201,11 @@ class ComposeActivity : Toast.LENGTH_SHORT ).show() } else { - uris.forEach { uri -> - pickMedia(uri) - } + viewModel.pickMedia( + uris.map { uri -> + ComposeViewModel.MediaData(uri) + } + ) } } @@ -195,22 +216,20 @@ class ComposeActivity : viewModel.cropImageItemOld?.let { itemOld -> val size = getMediaSize(contentResolver, uriNew) - lifecycleScope.launch { - viewModel.addMediaToQueue( - itemOld.type, - uriNew, - size, - itemOld.description, - // Intentionally reset focus when cropping - null, - itemOld - ) - } + viewModel.addMediaToQueue( + type = itemOld.type, + uri = uriNew, + mediaSize = size, + description = itemOld.description, + // Intentionally reset focus when cropping + focus = null, + replaceItem = itemOld + ) } } else if (result == CropImage.CancelledResult) { - Log.w("ComposeActivity", "Edit image cancelled by user") + Log.w(TAG, "Edit image cancelled by user") } else { - Log.w("ComposeActivity", "Edit image failed: " + result.error) + Log.w(TAG, "Edit image failed: " + result.error) displayTransientMessage(R.string.error_image_edit_failed) } viewModel.cropImageItemOld = null @@ -245,6 +264,18 @@ class ComposeActivity : } setContentView(binding.root) + binding.composeBottomBar.setOnWindowInsetsChangeListener { windowInsets -> + val insets = windowInsets.getInsets(systemBars() or ime()) + val bottomBarHeight = resources.getDimensionPixelSize(R.dimen.compose_bottom_bar_height) + val bottomBarPadding = resources.getDimensionPixelSize(R.dimen.compose_bottom_bar_padding_vertical) + binding.composeBottomBar.updatePadding(bottom = insets.bottom + bottomBarPadding) + binding.addMediaBottomSheet.updatePadding(bottom = insets.bottom + bottomBarHeight) + binding.emojiView.updatePadding(bottom = insets.bottom + bottomBarHeight) + binding.composeOptionsBottomSheet.updatePadding(bottom = insets.bottom + bottomBarHeight) + binding.composeScheduleView.updatePadding(bottom = insets.bottom + bottomBarHeight) + binding.composeMainScrollView.updateLayoutParams { bottomMargin = insets.bottom + bottomBarHeight } + } + setupActionBar() setupAvatar(activeAccount) @@ -273,17 +304,13 @@ class ComposeActivity : /* If the composer is started up as a reply to another post, override the "starting" state * based on what the intent from the reply request passes. */ - val composeOptions: ComposeOptions? = IntentCompat.getParcelableExtra( - intent, - COMPOSE_OPTIONS_EXTRA, - ComposeOptions::class.java - ) + val composeOptions: ComposeOptions? = intent.getParcelableExtraCompat(COMPOSE_OPTIONS_EXTRA) viewModel.setup(composeOptions) setupButtons() subscribeToUpdates(mediaAdapter) - if (accountManager.shouldDisplaySelfUsername(this)) { + if (accountManager.shouldDisplaySelfUsername()) { binding.composeUsernameView.text = getString( R.string.compose_active_account_description, activeAccount.fullName @@ -300,7 +327,7 @@ class ComposeActivity : } if (!composeOptions?.scheduledAt.isNullOrEmpty()) { - binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt) + binding.composeScheduleView.setDateTime(composeOptions.scheduledAt) } setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount)) @@ -311,11 +338,9 @@ class ComposeActivity : /* Finally, overwrite state with data from saved instance state. */ savedInstanceState?.let { - photoUploadUri = BundleCompat.getParcelable(it, PHOTO_UPLOAD_URI_KEY, Uri::class.java) + photoUploadUri = it.getParcelableCompat(PHOTO_UPLOAD_URI_KEY) - (it.getSerializable(VISIBILITY_KEY) as Status.Visibility).apply { - setStatusVisibility(this) - } + setStatusVisibility(it.getSerializableCompat(VISIBILITY_KEY)!!) it.getBoolean(CONTENT_WARNING_VISIBLE_KEY).apply { viewModel.contentWarningChanged(this) @@ -340,22 +365,15 @@ class ComposeActivity : if (type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) { when (intent.action) { Intent.ACTION_SEND -> { - IntentCompat.getParcelableExtra( - intent, - Intent.EXTRA_STREAM, - Uri::class.java - )?.let { uri -> - pickMedia(uri) + intent.getParcelableExtraCompat(Intent.EXTRA_STREAM)?.let { uri -> + viewModel.pickMedia(uri) } } Intent.ACTION_SEND_MULTIPLE -> { - IntentCompat.getParcelableArrayListExtra( - intent, - Intent.EXTRA_STREAM, - Uri::class.java - )?.forEach { uri -> - pickMedia(uri) - } + intent.getParcelableArrayListExtraCompat(Intent.EXTRA_STREAM) + ?.map { uri -> + ComposeViewModel.MediaData(uri) + }?.let(viewModel::pickMedia) } } } @@ -463,9 +481,9 @@ class ComposeActivity : binding.composeEditField.setAdapter( ComposeAutoCompleteAdapter( this, - preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), - preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), - preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true) + animateAvatar = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + showBotBadge = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true) ) ) binding.composeEditField.setTokenizer(ComposeTokenizer()) @@ -474,9 +492,9 @@ class ComposeActivity : binding.composeEditField.setSelection(binding.composeEditField.length()) val mentionColour = binding.composeEditField.linkTextColors.defaultColor - highlightSpans(binding.composeEditField.text, mentionColour) + binding.composeEditField.text.highlightSpans(mentionColour, highlightFinders) binding.composeEditField.doAfterTextChanged { editable -> - highlightSpans(editable!!, mentionColour) + editable!!.highlightSpans(mentionColour, highlightFinders) updateVisibleCharactersLeft() viewModel.updateContent(editable.toString()) } @@ -558,16 +576,25 @@ class ComposeActivity : lifecycleScope.launch { viewModel.uploadError.collect { throwable -> - if (throwable is UploadServerError) { - displayTransientMessage(throwable.errorMessage) - } else { - displayTransientMessage( - getString( - R.string.error_media_upload_sending_fmt, - throwable.message - ) + val errorString = when (throwable) { + is UploadServerError -> throwable.errorMessage + is FileSizeException -> { + val decimalFormat = DecimalFormat("0.##") + val allowedSizeInMb = throwable.allowedSizeInBytes.toDouble() / (1024 * 1024) + val formattedSize = decimalFormat.format(allowedSizeInMb) + getString(R.string.error_multimedia_size_limit, formattedSize) + } + is VideoOrImageException -> getString( + R.string.error_media_upload_image_or_video + ) + is CouldNotOpenFileException -> getString(R.string.error_media_upload_opening) + is MediaTypeException -> getString(R.string.error_media_upload_opening) + else -> getString( + R.string.error_media_upload_sending_fmt, + throwable.message ) } + displayTransientMessage(errorString) } } @@ -586,6 +613,11 @@ class ComposeActivity : scheduleBehavior = BottomSheetBehavior.from(binding.composeScheduleView) emojiBehavior = BottomSheetBehavior.from(binding.emojiView) + composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN + emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN + val bottomSheetCallback = object : BottomSheetCallback() { override fun onStateChanged(bottomSheet: View, newState: Int) { updateOnBackPressedCallbackState() @@ -823,31 +855,29 @@ class ComposeActivity : binding.descriptionMissingWarningButton.hide() } else { binding.composeHideMediaButton.show() - @ColorInt val color = if (contentWarningShown) { + @AttrRes val color = if (contentWarningShown) { binding.composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp) binding.composeHideMediaButton.isClickable = false - getColor(R.color.transparent_chinwag_green) + materialR.attr.colorPrimary } else { binding.composeHideMediaButton.isClickable = true if (markMediaSensitive) { binding.composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp) - getColor(R.color.chinwag_green) + materialR.attr.colorPrimary } else { binding.composeHideMediaButton.setImageResource(R.drawable.ic_eye_24dp) - MaterialColors.getColor( - binding.composeHideMediaButton, - android.R.attr.textColorTertiary - ) + android.R.attr.textColorTertiary } } - binding.composeHideMediaButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + binding.composeHideMediaButton.drawable.setTint( + MaterialColors.getColor( + binding.composeHideMediaButton, + color + ) + ) - var oneMediaWithoutDescription = false - for (media in viewModel.media.value) { - if (media.description.isNullOrEmpty()) { - oneMediaWithoutDescription = true - break - } + val oneMediaWithoutDescription = viewModel.media.value.any { media -> + media.description.isNullOrEmpty() } binding.descriptionMissingWarningButton.visibility = if (oneMediaWithoutDescription) View.VISIBLE else View.GONE } @@ -858,15 +888,16 @@ class ComposeActivity : // Can't reschedule a published status enableButton(binding.composeScheduleButton, clickable = false, colorActive = false) } else { - @ColorInt val color = if (binding.composeScheduleView.time == null) { + @ColorInt val color = MaterialColors.getColor( binding.composeScheduleButton, - android.R.attr.textColorTertiary + if (binding.composeScheduleView.time == null) { + android.R.attr.textColorTertiary + } else { + materialR.attr.colorPrimary + } ) - } else { - getColor(R.color.chinwag_green) - } - binding.composeScheduleButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + binding.composeScheduleButton.drawable.setTint(color) } } @@ -937,7 +968,7 @@ class ComposeActivity : val errorMessage = getString( R.string.error_no_custom_emojis, - accountManager.activeAccount!!.domain + activeAccount ) displayTransientMessage(errorMessage) } else { @@ -965,32 +996,16 @@ class ComposeActivity : } private fun onMediaPick() { - addMediaBehavior.addBottomSheetCallback( - object : 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) {} - } - ) - addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + pickMediaFilePermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) + } else { + pickMediaFileLauncher.launch(true) + } + addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) } private fun openPollDialog() = lifecycleScope.launch { - addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN val instanceParams = viewModel.instanceInfo.first() showAddPollDialog( context = this@ComposeActivity, @@ -1039,7 +1054,7 @@ class ComposeActivity : } override fun onVisibilityChanged(visibility: Status.Visibility) { - composeOptionsBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN viewModel.changeStatusVisibility(visibility) } @@ -1061,7 +1076,7 @@ class ComposeActivity : binding.composeCharactersLeftView.text = String.format(Locale.getDefault(), "%d", remainingLength) val textColor = if (remainingLength < 0) { - getColor(R.color.tusky_red) + getColor(R.color.warning_color) } else { MaterialColors.getColor( binding.composeCharactersLeftView, @@ -1096,12 +1111,27 @@ class ComposeActivity : if (contentInfo.clip.description.hasMimeType("image/*")) { val split = contentInfo.partition { item: ClipData.Item -> item.uri != null } split.first?.let { content -> - for (i in 0 until content.clip.itemCount) { - pickMedia( - content.clip.getItemAt(i).uri, - contentInfo.clip.description.label as String? - ) + val description = (contentInfo.clip.description.label as String?)?.let { + // The Gboard android keyboard attaches this text whenever the user + // pastes something from the keyboard's suggestion bar. + // Due to different end user locales, the exact text may vary, but at + // least in version 13.4.08, all of the translations contained the + // string "Gboard". + if ("Gboard" in it) { + null + } else { + it + } } + + viewModel.pickMedia( + content.clip.map { clipItem -> + ComposeViewModel.MediaData( + uri = clipItem.uri, + description = description + ) + } + ) } return split.second } @@ -1130,33 +1160,8 @@ class ComposeActivity : } } - override fun onRequestPermissionsResult( - requestCode: Int, - permissions: Array, - grantResults: IntArray - ) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - - if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) { - if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - pickMediaFile.launch(true) - } else { - Snackbar.make( - binding.activityCompose, - R.string.error_media_upload_permission, - Snackbar.LENGTH_SHORT - ).apply { - setAction(R.string.action_retry) { onMediaPick() } - // necessary so snackbar is shown over everything - view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) - show() - } - } - } - } - private fun initiateCameraApp() { - addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN val photoFile: File = try { createNewImageFile(this) @@ -1170,8 +1175,7 @@ class ComposeActivity : this, BuildConfig.APPLICATION_ID + ".fileprovider", photoFile - ) - takePicture.launch(photoUploadUri) + ).also { uri -> takePictureLauncher.launch(uri) } } private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) { @@ -1198,7 +1202,7 @@ class ComposeActivity : } ) binding.addPollTextActionTextView.setTextColor(textColor) - binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN) + binding.addPollTextActionTextView.compoundDrawablesRelative[0].setTint(textColor) } private fun editImageInQueue(item: QueuedMedia) { @@ -1231,65 +1235,28 @@ class ComposeActivity : viewModel.removeMediaFromQueue(item) } - private fun sanitizePickMediaDescription(description: String?): String? { - if (description == null) { - return null - } - - // The Gboard android keyboard attaches this text whenever the user - // pastes something from the keyboard's suggestion bar. - // Due to different end user locales, the exact text may vary, but at - // least in version 13.4.08, all of the translations contained the - // string "Gboard". - if ("Gboard" in description) { - return null - } - - return description - } - - private fun pickMedia(uri: Uri, description: String? = null) { - val sanitizedDescription = sanitizePickMediaDescription(description) - - lifecycleScope.launch { - viewModel.pickMedia(uri, sanitizedDescription).onFailure { throwable -> - val errorString = when (throwable) { - is FileSizeException -> { - val decimalFormat = DecimalFormat("0.##") - val allowedSizeInMb = throwable.allowedSizeInBytes.toDouble() / (1024 * 1024) - val formattedSize = decimalFormat.format(allowedSizeInMb) - getString(R.string.error_multimedia_size_limit, formattedSize) - } - is VideoOrImageException -> getString( - R.string.error_media_upload_image_or_video - ) - else -> getString(R.string.error_media_upload_opening) - } - displayTransientMessage(errorString) - } - } - } - private fun showContentWarning(show: Boolean) { TransitionManager.beginDelayedTransition( binding.composeContentWarningBar.parent as ViewGroup ) - @ColorInt val color = if (show) { + @AttrRes val color = if (show) { binding.composeContentWarningBar.show() binding.composeContentWarningField.setSelection( binding.composeContentWarningField.text.length ) binding.composeContentWarningField.requestFocus() - getColor(R.color.chinwag_green) + materialR.attr.colorPrimary } else { binding.composeContentWarningBar.hide() binding.composeEditField.requestFocus() - MaterialColors.getColor( - binding.composeContentWarningButton, - android.R.attr.textColorTertiary - ) + android.R.attr.textColorTertiary } - binding.composeContentWarningButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + binding.composeContentWarningButton.drawable.setTint( + MaterialColors.getColor( + binding.composeHideMediaButton, + color + ) + ) } override fun onOptionsItemSelected(item: MenuItem): Boolean { @@ -1344,14 +1311,14 @@ class ComposeActivity : private fun getSaveAsDraftOrDiscardDialog( contentText: String, contentWarning: String - ): AlertDialog.Builder { + ): MaterialAlertDialogBuilder { val warning = if (viewModel.media.value.isNotEmpty()) { R.string.compose_save_draft_loses_media } else { R.string.compose_save_draft } - return AlertDialog.Builder(this) + return MaterialAlertDialogBuilder(this) .setMessage(warning) .setPositiveButton(R.string.action_save) { _, _ -> viewModel.stopUploads() @@ -1370,14 +1337,14 @@ class ComposeActivity : private fun getUpdateDraftOrDiscardDialog( contentText: String, contentWarning: String - ): AlertDialog.Builder { + ): MaterialAlertDialogBuilder { val warning = if (viewModel.media.value.isNotEmpty()) { R.string.compose_save_draft_loses_media } else { R.string.compose_save_draft } - return AlertDialog.Builder(this) + return MaterialAlertDialogBuilder(this) .setMessage(warning) .setPositiveButton(R.string.action_save) { _, _ -> viewModel.stopUploads() @@ -1393,8 +1360,8 @@ class ComposeActivity : * User is editing a post (scheduled, or posted), and can either go back to editing, or * discard the changes. */ - private fun getContinueEditingOrDiscardDialog(): AlertDialog.Builder { - return AlertDialog.Builder(this) + private fun getContinueEditingOrDiscardDialog(): MaterialAlertDialogBuilder { + return MaterialAlertDialogBuilder(this) .setMessage(R.string.compose_unsaved_changes) .setPositiveButton(R.string.action_continue_edit) { _, _ -> // Do nothing, dialog will dismiss, user can continue editing @@ -1409,8 +1376,8 @@ class ComposeActivity : * User is editing an existing draft and making it empty. * The user can either delete the empty draft or go back to editing. */ - private fun getDeleteEmptyDraftOrContinueEditing(): AlertDialog.Builder { - return AlertDialog.Builder(this) + private fun getDeleteEmptyDraftOrContinueEditing(): MaterialAlertDialogBuilder { + return MaterialAlertDialogBuilder(this) .setMessage(R.string.compose_delete_draft) .setPositiveButton(R.string.action_delete) { _, _ -> viewModel.deleteDraft() @@ -1429,19 +1396,7 @@ class ComposeActivity : private fun saveDraftAndFinish(contentText: String, contentWarning: String) { lifecycleScope.launch { - val dialog = if (viewModel.shouldShowSaveDraftDialog()) { - ProgressDialog.show( - this@ComposeActivity, - null, - getString(R.string.saving_draft), - true, - false - ) - } else { - null - } viewModel.saveDraft(contentText, contentWarning) - dialog?.cancel() finish() } } @@ -1462,30 +1417,6 @@ class ComposeActivity : } } - data class QueuedMedia( - val localId: Int, - val uri: Uri, - val type: Type, - val mediaSize: Long, - val uploadPercent: Int = 0, - val id: String? = null, - val description: String? = null, - val focus: Attachment.Focus? = null, - val state: State - ) { - enum class Type { - IMAGE, - VIDEO, - AUDIO - } - enum class State { - UPLOADING, - UNPROCESSED, - PROCESSED, - PUBLISHED - } - } - override fun onTimeSet(time: String?) { viewModel.updateScheduledAt(time) if (verifyScheduledTime()) { @@ -1550,7 +1481,6 @@ class ComposeActivity : companion object { private const val TAG = "ComposeActivity" // logging tag - private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1 internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS" private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI" diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.kt index c37644cee..a7b2d8a16 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.kt @@ -37,7 +37,9 @@ class ComposeAutoCompleteAdapter( private val autocompletionProvider: AutocompletionProvider, private val animateAvatar: Boolean, private val animateEmojis: Boolean, - private val showBotBadge: Boolean + private val showBotBadge: Boolean, + // if true, @ # : are returned in the result, otherwise only the raw value + private val withDecoration: Boolean = true, ) : BaseAdapter(), Filterable { private var resultList: List = emptyList() @@ -52,36 +54,35 @@ class ComposeAutoCompleteAdapter( return position.toLong() } - override fun getFilter(): Filter { - return object : Filter() { + override fun getFilter() = object : Filter() { - override fun convertResultToString(resultValue: Any): CharSequence { - return when (resultValue) { - is AutocompleteResult.AccountResult -> formatUsername(resultValue) - is AutocompleteResult.HashtagResult -> formatHashtag(resultValue) - is AutocompleteResult.EmojiResult -> formatEmoji(resultValue) - else -> "" - } + override fun convertResultToString(resultValue: Any): CharSequence { + return when (resultValue) { + is AutocompleteResult.AccountResult -> if (withDecoration) "@${resultValue.account.username}" else resultValue.account.username + is AutocompleteResult.HashtagResult -> if (withDecoration) "#${resultValue.hashtag}" else resultValue.hashtag + is AutocompleteResult.EmojiResult -> if (withDecoration) ":${resultValue.emoji.shortcode}:" else resultValue.emoji.shortcode + else -> "" } + } - @WorkerThread - override fun performFiltering(constraint: CharSequence?): FilterResults { - val filterResults = FilterResults() - if (constraint != null) { - val results = autocompletionProvider.search(constraint.toString()) - filterResults.values = results - filterResults.count = results.size - } - return filterResults + @WorkerThread + override fun performFiltering(constraint: CharSequence?): FilterResults { + val filterResults = FilterResults() + if (constraint != null) { + val results = autocompletionProvider.search(constraint.toString()) + filterResults.values = results + filterResults.count = results.size } + return filterResults + } - override fun publishResults(constraint: CharSequence?, results: FilterResults) { - if (results.count > 0) { - resultList = results.values as List - notifyDataSetChanged() - } else { - notifyDataSetInvalidated() - } + @Suppress("UNCHECKED_CAST") + override fun publishResults(constraint: CharSequence?, results: FilterResults) { + if (results.count > 0) { + resultList = results.values as List + notifyDataSetChanged() + } else { + notifyDataSetInvalidated() } } } @@ -121,7 +122,7 @@ class ComposeAutoCompleteAdapter( } is ItemAutocompleteHashtagBinding -> { val result = getItem(position) as AutocompleteResult.HashtagResult - binding.root.text = formatHashtag(result) + binding.root.text = context.getString(R.string.hashtag_format, result.hashtag) } is ItemAutocompleteEmojiBinding -> { val emojiResult = getItem(position) as AutocompleteResult.EmojiResult @@ -161,17 +162,5 @@ class ComposeAutoCompleteAdapter( private const val ACCOUNT_VIEW_TYPE = 0 private const val HASHTAG_VIEW_TYPE = 1 private const val EMOJI_VIEW_TYPE = 2 - - private fun formatUsername(result: AutocompleteResult.AccountResult): String { - return String.format("@%s", result.account.username) - } - - private fun formatHashtag(result: AutocompleteResult.HashtagResult): String { - return String.format("#%s", result.hashtag) - } - - private fun formatEmoji(result: AutocompleteResult.EmojiResult): String { - return String.format(":%s:", result.emoji.shortcode) - } } } 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 5ba8f9a06..61c3d767a 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 @@ -22,7 +22,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeKind -import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo @@ -38,8 +37,10 @@ import com.keylesspalace.tusky.service.MediaToSend import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.StatusToSend import com.keylesspalace.tusky.util.randomAlphanumericString +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -54,8 +55,8 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext +@HiltViewModel class ComposeViewModel @Inject constructor( private val api: MastodonApi, private val accountManager: AccountManager, @@ -90,7 +91,7 @@ class ComposeViewModel @Inject constructor( .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) private val _markMediaAsSensitive = - MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false) + MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity == true) val markMediaAsSensitive: StateFlow = _markMediaAsSensitive.asStateFlow() private val _statusVisibility = MutableStateFlow(Status.Visibility.UNKNOWN) @@ -125,31 +126,31 @@ class ComposeViewModel @Inject constructor( private var setupComplete = false - suspend fun pickMedia( - mediaUri: Uri, - description: String? = null, - focus: Attachment.Focus? = null - ): Result = withContext( - Dispatchers.IO - ) { - try { - val (type, uri, size) = mediaUploader.prepareMedia(mediaUri, instanceInfo.first()) - val mediaItems = _media.value - if (type != QueuedMedia.Type.IMAGE && - mediaItems.isNotEmpty() && - mediaItems[0].type == QueuedMedia.Type.IMAGE - ) { - Result.failure(VideoOrImageException()) - } else { - val queuedMedia = addMediaToQueue(type, uri, size, description, focus) - Result.success(queuedMedia) - } - } catch (e: Exception) { - Result.failure(e) + fun pickMedia(uri: Uri) { + pickMedia(listOf(MediaData(uri))) + } + + fun pickMedia(mediaList: List) = viewModelScope.launch(Dispatchers.IO) { + val instanceInfo = instanceInfo.first() + mediaList.map { m -> + async { mediaUploader.prepareMedia(m.uri, instanceInfo) } + }.forEachIndexed { index, preparedMedia -> + preparedMedia.await().fold({ (type, uri, size) -> + if (type != QueuedMedia.Type.IMAGE && + _media.value.firstOrNull()?.type == QueuedMedia.Type.IMAGE + ) { + _uploadError.emit(VideoOrImageException()) + } else { + val pickedMedia = mediaList[index] + addMediaToQueue(type, uri, size, pickedMedia.description, pickedMedia.focus) + } + }, { error -> + _uploadError.emit(error) + }) } } - suspend fun addMediaToQueue( + fun addMediaToQueue( type: QueuedMedia.Type, uri: Uri, mediaSize: Long, @@ -157,20 +158,17 @@ class ComposeViewModel @Inject constructor( focus: Attachment.Focus? = null, replaceItem: QueuedMedia? = null ): QueuedMedia { - var stashMediaItem: QueuedMedia? = null + val mediaItem = QueuedMedia( + localId = mediaUploader.getNewLocalMediaId(), + uri = uri, + type = type, + mediaSize = mediaSize, + description = description, + focus = focus, + state = QueuedMedia.State.UPLOADING + ) _media.update { mediaList -> - val mediaItem = QueuedMedia( - localId = mediaUploader.getNewLocalMediaId(), - uri = uri, - type = type, - mediaSize = mediaSize, - description = description, - focus = focus, - state = QueuedMedia.State.UPLOADING - ) - stashMediaItem = mediaItem - if (replaceItem != null) { mediaUploader.cancelUploadScope(replaceItem.localId) mediaList.map { @@ -180,7 +178,6 @@ class ComposeViewModel @Inject constructor( mediaList + mediaItem } } - val mediaItem = stashMediaItem!! // stashMediaItem is always non-null and uncaptured at this point, but Kotlin doesn't know that viewModelScope.launch { mediaUploader @@ -191,6 +188,7 @@ class ComposeViewModel @Inject constructor( val newMediaItem = when (event) { is UploadEvent.ProgressEvent -> item.copy(uploadPercent = event.percentage) + is UploadEvent.FinishedEvent -> item.copy( id = event.mediaId, @@ -327,13 +325,6 @@ class ComposeViewModel @Inject constructor( mediaUploader.cancelUploadScope(*_media.value.map { it.localId }.toIntArray()) } - fun shouldShowSaveDraftDialog(): Boolean { - // if any of the media files need to be downloaded first it could take a while, so show a loading dialog - return _media.value.any { mediaValue -> - mediaValue.uri.scheme == "https" - } - } - suspend fun saveDraft(content: String, contentWarning: String) { val mediaUris: MutableList = mutableListOf() val mediaDescriptions: MutableList = mutableListOf() @@ -386,7 +377,7 @@ class ComposeViewModel @Inject constructor( val tootToSend = StatusToSend( text = content, warningText = spoilerText, - visibility = _statusVisibility.value.serverString, + visibility = _statusVisibility.value.stringValue, sensitive = attachedMedia.isNotEmpty() && (_markMediaAsSensitive.value || _showContentWarning.value), media = attachedMedia, scheduledAt = _scheduledAt.value, @@ -453,6 +444,7 @@ class ComposeViewModel @Inject constructor( emptyList() }) } + ':' -> { val emojiList = emoji.replayCache.firstOrNull() ?: return emptyList() val incomplete = token.substring(1) @@ -465,6 +457,7 @@ class ComposeViewModel @Inject constructor( AutocompleteResult.EmojiResult(emoji) } } + else -> { Log.w(TAG, "Unexpected autocompletion token: $token") emptyList() @@ -478,16 +471,20 @@ class ComposeViewModel @Inject constructor( } composeKind = composeOptions?.kind ?: ComposeKind.NEW + inReplyToId = composeOptions?.inReplyToId - val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy + val activeAccount = accountManager.activeAccount!! + val preferredVisibility = if (inReplyToId != null) { + activeAccount.defaultReplyPrivacy.toVisibilityOr(activeAccount.defaultPostPrivacy) + } else { + activeAccount.defaultPostPrivacy + } val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN - startingVisibility = Status.Visibility.byNum( - preferredVisibility.num.coerceAtLeast(replyVisibility.num) + startingVisibility = Status.Visibility.fromInt( + preferredVisibility.int.coerceAtLeast(replyVisibility.int) ) - inReplyToId = composeOptions?.inReplyToId - modifiedInitialState = composeOptions?.modifiedInitialState == true val contentWarning = composeOptions?.contentWarning @@ -502,11 +499,9 @@ class ComposeViewModel @Inject constructor( val draftAttachments = composeOptions?.draftAttachments if (draftAttachments != null) { // when coming from DraftActivity - viewModelScope.launch { - draftAttachments.forEach { attachment -> - pickMedia(attachment.uri, attachment.description, attachment.focus) - } - } + draftAttachments.map { attachment -> + MediaData(attachment.uri, attachment.description, attachment.focus) + }.let(::pickMedia) } else { composeOptions?.mediaAttachments?.forEach { a -> // when coming from redraft or ScheduledTootActivity @@ -523,10 +518,11 @@ class ComposeViewModel @Inject constructor( scheduledTootId = composeOptions?.scheduledTootId originalStatusId = composeOptions?.statusId startingText = composeOptions?.content + currentContent = composeOptions?.content postLanguage = composeOptions?.language val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN - if (tootVisibility.num != Status.Visibility.UNKNOWN.num) { + if (tootVisibility.int != Status.Visibility.UNKNOWN.int) { startingVisibility = tootVisibility } _statusVisibility.value = startingVisibility @@ -584,6 +580,36 @@ class ComposeViewModel @Inject constructor( CONTINUE_EDITING_OR_DISCARD_CHANGES, // editing post CONTINUE_EDITING_OR_DISCARD_DRAFT // edit draft } + + data class QueuedMedia( + val localId: Int, + val uri: Uri, + val type: Type, + val mediaSize: Long, + val uploadPercent: Int = 0, + val id: String? = null, + val description: String? = null, + val focus: Attachment.Focus? = null, + val state: State + ) { + enum class Type { + IMAGE, + VIDEO, + AUDIO + } + enum class State { + UPLOADING, + UNPROCESSED, + PROCESSED, + PUBLISHED + } + } + + data class MediaData( + val uri: Uri, + val description: String? = null, + val focus: Attachment.Focus? = null + ) } /** 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 bfc814293..ca7f8213f 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 @@ -21,8 +21,8 @@ import android.view.ViewGroup import android.widget.ImageView import android.widget.PopupMenu import androidx.constraintlayout.widget.ConstraintLayout -import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy @@ -31,18 +31,25 @@ import com.keylesspalace.tusky.components.compose.view.ProgressImageView class MediaPreviewAdapter( context: Context, - private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit, - private val onAddFocus: (ComposeActivity.QueuedMedia) -> Unit, - private val onEditImage: (ComposeActivity.QueuedMedia) -> Unit, - private val onRemove: (ComposeActivity.QueuedMedia) -> Unit -) : RecyclerView.Adapter() { + private val onAddCaption: (ComposeViewModel.QueuedMedia) -> Unit, + private val onAddFocus: (ComposeViewModel.QueuedMedia) -> Unit, + private val onEditImage: (ComposeViewModel.QueuedMedia) -> Unit, + private val onRemove: (ComposeViewModel.QueuedMedia) -> Unit +) : ListAdapter( + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: ComposeViewModel.QueuedMedia, + newItem: ComposeViewModel.QueuedMedia + ) = oldItem.localId == newItem.localId - fun submitList(list: List) { - this.differ.submitList(list) + override fun areContentsTheSame( + oldItem: ComposeViewModel.QueuedMedia, + newItem: ComposeViewModel.QueuedMedia + ) = oldItem == newItem } +) { - private fun onMediaClick(position: Int, view: View) { - val item = differ.currentList[position] + private fun onMediaClick(item: ComposeViewModel.QueuedMedia, view: View) { val popup = PopupMenu(view.context, view) val addCaptionId = 1 val addFocusId = 2 @@ -50,9 +57,9 @@ class MediaPreviewAdapter( val removeId = 4 popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) - if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) { + if (item.type == ComposeViewModel.QueuedMedia.Type.IMAGE) { popup.menu.add(0, addFocusId, 0, R.string.action_set_focus) - if (item.state != ComposeActivity.QueuedMedia.State.PUBLISHED) { + if (item.state != ComposeViewModel.QueuedMedia.State.PUBLISHED) { // Already-published items can't be edited popup.menu.add(0, editImageId, 0, R.string.action_edit_image) } @@ -73,17 +80,15 @@ class MediaPreviewAdapter( private val thumbnailViewSize = context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size) - override fun getItemCount(): Int = differ.currentList.size - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PreviewViewHolder { return PreviewViewHolder(ProgressImageView(parent.context)) } override fun onBindViewHolder(holder: PreviewViewHolder, position: Int) { - val item = differ.currentList[position] + val item = getItem(position) holder.progressImageView.setChecked(!item.description.isNullOrEmpty()) holder.progressImageView.setProgress(item.uploadPercent) - if (item.type == ComposeActivity.QueuedMedia.Type.AUDIO) { + if (item.type == ComposeViewModel.QueuedMedia.Type.AUDIO) { // TODO: Fancy waveform display? holder.progressImageView.setImageResource(R.drawable.ic_music_box_preview_24dp) } else { @@ -108,26 +113,11 @@ class MediaPreviewAdapter( glide.into(imageView) } - } - private val differ = AsyncListDiffer( - this, - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: ComposeActivity.QueuedMedia, - newItem: ComposeActivity.QueuedMedia - ): Boolean { - return oldItem.localId == newItem.localId - } - - override fun areContentsTheSame( - oldItem: ComposeActivity.QueuedMedia, - newItem: ComposeActivity.QueuedMedia - ): Boolean { - return oldItem == newItem - } + holder.progressImageView.setOnClickListener { + onMediaClick(item, holder.progressImageView) } - ) + } inner class PreviewViewHolder(val progressImageView: ProgressImageView) : RecyclerView.ViewHolder(progressImageView) { @@ -140,9 +130,6 @@ class MediaPreviewAdapter( layoutParams.setMargins(margin, 0, margin, marginBottom) progressImageView.layoutParams = layoutParams progressImageView.scaleType = ImageView.ScaleType.CENTER_CROP - progressImageView.setOnClickListener { - onMediaClick(bindingAdapterPosition, progressImageView) - } } } } 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 4f32d8dc0..e4d6e73a6 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 @@ -27,7 +27,7 @@ import androidx.core.content.FileProvider import androidx.core.net.toUri import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia +import com.keylesspalace.tusky.components.compose.ComposeViewModel.QueuedMedia import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo import com.keylesspalace.tusky.network.MediaUploadApi import com.keylesspalace.tusky.network.asRequestBody @@ -36,10 +36,10 @@ import com.keylesspalace.tusky.util.getImageSquarePixels import com.keylesspalace.tusky.util.getMediaSize import com.keylesspalace.tusky.util.getServerErrorMessage import com.keylesspalace.tusky.util.randomAlphanumericString +import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import java.io.IOException import javax.inject.Inject -import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -92,15 +92,16 @@ class MediaTypeException : Exception() class CouldNotOpenFileException : Exception() class UploadServerError(val errorMessage: String) : Exception() -@Singleton class MediaUploader @Inject constructor( - private val context: Context, + @ApplicationContext private val context: Context, private val mediaUploadApi: MediaUploadApi ) { - private val uploads = mutableMapOf() - - private var mostRecentId: Int = 0 + private companion object { + private const val TAG = "MediaUploader" + private val uploads = mutableMapOf() + private var mostRecentId: Int = 0 + } fun getNewLocalMediaId(): Int { return mostRecentId++ @@ -150,7 +151,7 @@ class MediaUploader @Inject constructor( } } - fun prepareMedia(inUri: Uri, instanceInfo: InstanceInfo): PreparedMedia { + fun prepareMedia(inUri: Uri, instanceInfo: InstanceInfo): Result = runCatching { var mediaSize = MEDIA_SIZE_UNKNOWN var uri = inUri val mimeType: String? @@ -216,9 +217,8 @@ class MediaUploader @Inject constructor( Log.w(TAG, "Could not determine file size of upload") throw MediaTypeException() } - if (mimeType != null) { - return when (mimeType.substring(0, mimeType.indexOf('/'))) { + when (mimeType.substring(0, mimeType.indexOf('/'))) { "video" -> { if (mediaSize > instanceInfo.videoSizeLimit) { throw FileSizeException(instanceInfo.videoSizeLimit) @@ -246,7 +246,7 @@ class MediaUploader @Inject constructor( private val contentResolver = context.contentResolver - private suspend fun upload(media: QueuedMedia): Flow { + private fun upload(media: QueuedMedia): Flow { return callbackFlow { var mimeType = contentResolver.getType(media.uri) @@ -264,12 +264,8 @@ class MediaUploader @Inject constructor( } val map = MimeTypeMap.getSingleton() val fileExtension = map.getExtensionFromMimeType(mimeType) - val filename = "%s_%d_%s.%s".format( - context.getString(R.string.app_name), - System.currentTimeMillis(), - randomAlphanumericString(10), - fileExtension - ) + val filename = + "${context.getString(R.string.app_name)}_${System.currentTimeMillis()}_${randomAlphanumericString(10)}.$fileExtension" if (mimeType == null) mimeType = "multipart/form-data" @@ -327,8 +323,4 @@ class MediaUploader @Inject constructor( return media.type == QueuedMedia.Type.IMAGE && (media.mediaSize > instanceInfo.imageSizeLimit || getImageSquarePixels(context.contentResolver, media.uri) > instanceInfo.imageMatrixLimit) } - - private companion object { - private const val TAG = "MediaUploader" - } } 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 1d8168db6..9a39d4156 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 @@ -20,8 +20,9 @@ package com.keylesspalace.tusky.components.compose.dialog import android.content.Context import android.view.LayoutInflater import android.view.WindowManager -import android.widget.ArrayAdapter import androidx.appcompat.app.AlertDialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.DialogAddPollBinding import com.keylesspalace.tusky.entity.NewPoll @@ -37,10 +38,16 @@ fun showAddPollDialog( ) { val binding = DialogAddPollBinding.inflate(LayoutInflater.from(context)) - val dialog = AlertDialog.Builder(context) + val inset = context.resources.getDimensionPixelSize(R.dimen.dialog_inset) + + val dialog = MaterialAlertDialogBuilder(context) .setIcon(R.drawable.ic_poll_24dp) .setTitle(R.string.create_poll_title) .setView(binding.root) + .setBackgroundInsetTop(inset) + .setBackgroundInsetEnd(inset) + .setBackgroundInsetBottom(inset) + .setBackgroundInsetStart(inset) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(android.R.string.ok, null) .create() @@ -63,9 +70,8 @@ fun showAddPollDialog( 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(androidx.appcompat.R.layout.support_simple_spinner_dropdown_item) - } + + binding.pollDurationDropDown.setSimpleItems(durationLabels.toTypedArray()) durations = durations.filter { it in minDuration..maxDuration } binding.addChoiceButton.setOnClickListener { @@ -79,23 +85,24 @@ fun showAddPollDialog( val secondsInADay = 60 * 60 * 24 val desiredDuration = poll?.expiresIn ?: secondsInADay - val pollDurationId = durations.indexOfLast { + var selectedDurationIndex = durations.indexOfLast { it <= desiredDuration } - binding.pollDurationSpinner.setSelection(pollDurationId) + binding.pollDurationDropDown.setText(durationLabels[selectedDurationIndex], false) + binding.pollDurationDropDown.setOnItemClickListener { _, _, position, _ -> + selectedDurationIndex = position + } - binding.multipleChoicesCheckBox.isChecked = poll?.multiple ?: false + binding.multipleChoicesCheckBox.isChecked = poll?.multiple == true dialog.setOnShowListener { val button = dialog.getButton(AlertDialog.BUTTON_POSITIVE) button.setOnClickListener { - val selectedPollDurationId = binding.pollDurationSpinner.selectedItemPosition - onUpdatePoll( NewPoll( options = adapter.pollOptions, - expiresIn = durations[selectedPollDurationId], + expiresIn = durations[selectedDurationIndex], multiple = binding.multipleChoicesCheckBox.isChecked ) ) @@ -106,6 +113,18 @@ fun showAddPollDialog( dialog.show() + // yes, SOFT_INPUT_ADJUST_RESIZE is deprecated, but without it the dropdown can get behind the keyboard + dialog.window?.setSoftInputMode( + WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE or WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE + ) + + binding.pollChoices.post { + val firstItemView = binding.pollChoices.layoutManager?.findViewByPosition(0) + val editText = firstItemView?.findViewById(R.id.optionEditText) + editText?.requestFocus() + editText?.setSelection(editText.length()) + } + // make the dialog focusable so the keyboard does not stay behind it dialog.window?.clearFlags( WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM 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 154c83ed8..1e14507ea 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 @@ -15,27 +15,27 @@ package com.keylesspalace.tusky.components.compose.dialog +import android.app.Dialog import android.content.Context +import android.graphics.drawable.Animatable import android.graphics.drawable.Drawable import android.net.Uri import android.os.Bundle import android.text.InputFilter -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import android.view.WindowManager import android.widget.LinearLayout -import androidx.core.os.BundleCompat import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment 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.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.DialogImageDescriptionBinding +import com.keylesspalace.tusky.util.getParcelableCompat import com.keylesspalace.tusky.util.hide -import com.keylesspalace.tusky.util.viewBinding // https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32 private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500 @@ -43,22 +43,35 @@ private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500 class CaptionDialog : DialogFragment() { private lateinit var listener: Listener - private val binding by viewBinding(DialogImageDescriptionBinding::bind) + private lateinit var binding: DialogImageDescriptionBinding - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle) + private var animatable: Animatable? = null + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId") + val inset = requireContext().resources.getDimensionPixelSize(R.dimen.dialog_inset) + return MaterialAlertDialogBuilder(requireContext()) + .setView(createView(savedInstanceState)) + .setBackgroundInsetTop(inset) + .setBackgroundInsetEnd(inset) + .setBackgroundInsetBottom(inset) + .setBackgroundInsetStart(inset) + .setPositiveButton(android.R.string.ok) { _, _ -> + listener.onUpdateDescription(localId, binding.imageDescriptionText.text.toString()) + } + .setNegativeButton(android.R.string.cancel, null) + .create() } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = inflater.inflate(R.layout.dialog_image_description, container, false) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + private fun createView(savedInstanceState: Bundle?): View { + binding = DialogImageDescriptionBinding.inflate(layoutInflater) val imageView = binding.imageDescriptionView imageView.maxZoom = 6f + val imageDescriptionText = binding.imageDescriptionText + imageDescriptionText.post { + imageDescriptionText.requestFocus() + imageDescriptionText.setSelection(imageDescriptionText.length()) + } binding.imageDescriptionText.hint = resources.getQuantityString( R.plurals.hint_describe_for_visually_impaired, @@ -71,18 +84,10 @@ class CaptionDialog : DialogFragment() { binding.imageDescriptionText.setText(it) } - binding.cancelButton.setOnClickListener { - dismiss() - } - val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId") - binding.okButton.setOnClickListener { - listener.onUpdateDescription(localId, binding.imageDescriptionText.text.toString()) - dismiss() - } - isCancelable = true + dialog?.setCanceledOnTouchOutside(false) // Dialog is full screen anyway. But without this, taps in navbar while keyboard is up can dismiss the dialog. - val previewUri = BundleCompat.getParcelable(requireArguments(), PREVIEW_URI_ARG, Uri::class.java) ?: error("Preview Uri is null") + val previewUri = arguments?.getParcelableCompat(PREVIEW_URI_ARG) ?: error("Preview Uri is null") // Load the image and manually set it into the ImageView because it doesn't have a fixed size. Glide.with(this) @@ -97,6 +102,23 @@ class CaptionDialog : DialogFragment() { resource: Drawable, transition: Transition? ) { + if (resource is Animatable) { + resource.callback = object : Drawable.Callback { + override fun invalidateDrawable(who: Drawable) { + imageView.invalidate() + } + + override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) { + imageView.postDelayed(what, `when`) + } + + override fun unscheduleDrawable(who: Drawable, what: Runnable) { + imageView.removeCallbacks(what) + } + } + resource.start() + animatable = resource + } imageView.setImageDrawable(resource) } @@ -105,6 +127,7 @@ class CaptionDialog : DialogFragment() { imageView.hide() } }) + return binding.root } override fun onStart() { @@ -112,7 +135,7 @@ class CaptionDialog : DialogFragment() { dialog?.apply { window?.setLayout( LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.MATCH_PARENT + LinearLayout.LayoutParams.WRAP_CONTENT ) window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) } @@ -128,6 +151,12 @@ class CaptionDialog : DialogFragment() { listener = context as? Listener ?: error("Activity is not ComposeCaptionDialog.Listener") } + override fun onDestroyView() { + super.onDestroyView() + animatable?.stop() + (animatable as? Drawable?)?.callback = null + } + interface Listener { fun onUpdateDescription(localId: Int, description: String) } 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 6cfbeedfc..2e013066c 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 @@ -20,7 +20,6 @@ import android.graphics.drawable.Drawable import android.net.Uri import android.view.WindowManager import android.widget.FrameLayout -import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope @@ -30,6 +29,7 @@ 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.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.databinding.DialogFocusBinding import com.keylesspalace.tusky.entity.Attachment.Focus import kotlinx.coroutines.launch @@ -50,10 +50,10 @@ fun T.makeFocusDialog( .downsample(DownsampleStrategy.CENTER_INSIDE) .listener(object : RequestListener { override fun onLoadFailed( - p0: GlideException?, - p1: Any?, - p2: Target, - p3: Boolean + error: GlideException?, + model: Any?, + target: Target, + isFirstResource: Boolean ): Boolean { return false } @@ -68,15 +68,20 @@ fun T.makeFocusDialog( val width = resource.intrinsicWidth val height = resource.intrinsicHeight - dialogBinding.focusIndicator.setImageSize(width, height) + val viewWidth = dialogBinding.imageView.width + val viewHeight = dialogBinding.imageView.height + + val scaledHeight = (viewWidth.toFloat() / width.toFloat()) * height + + dialogBinding.focusIndicator.setImageSize(viewWidth, scaledHeight.toInt()) // We want the dialog to be a little taller than the image, so you can slide your thumb past the image border, // but if it's *too* much taller that looks weird. See if a threshold has been crossed: if (width > height) { val maxHeight = dialogBinding.focusIndicator.maxAttractiveHeight() - if (dialogBinding.imageView.height > maxHeight) { - val verticalShrinkLayout = FrameLayout.LayoutParams(width, maxHeight) + if (viewHeight > maxHeight) { + val verticalShrinkLayout = FrameLayout.LayoutParams(viewWidth, maxHeight) dialogBinding.imageView.layoutParams = verticalShrinkLayout dialogBinding.focusIndicator.layoutParams = verticalShrinkLayout } @@ -93,7 +98,7 @@ fun T.makeFocusDialog( dialog.dismiss() } - val dialog = AlertDialog.Builder(this) + val dialog = MaterialAlertDialogBuilder(this) .setView(dialogBinding.root) .setPositiveButton(android.R.string.ok, okListener) .setNegativeButton(android.R.string.cancel, null) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.kt index a68906d9e..b061d6298 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.kt @@ -14,12 +14,13 @@ * see . */ package com.keylesspalace.tusky.components.compose.view +import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.content.res.AppCompatResources import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.content.ContextCompat import com.google.android.material.datepicker.CalendarConstraints import com.google.android.material.datepicker.DateValidatorPointForward import com.google.android.material.datepicker.MaterialDatePicker @@ -80,19 +81,16 @@ class ComposeScheduleView } val scheduled = scheduleDateTimeUtc!!.time - binding.scheduledDateTime.text = String.format( - "%s %s", - dateFormat.format(scheduled), - timeFormat.format(scheduled) - ) + @SuppressLint("SetTextI18n") + binding.scheduledDateTime.text = "${dateFormat.format(scheduled)} ${timeFormat.format(scheduled)}" verifyScheduledTime(scheduled) } private fun setEditIcons() { - val icon = ContextCompat.getDrawable(context, R.drawable.ic_create_24dp) ?: return + val icon = AppCompatResources.getDrawable(context, R.drawable.ic_create_24dp) ?: return val size = binding.scheduledDateTime.lineHeight icon.setBounds(0, 0, size, size) - binding.scheduledDateTime.setCompoundDrawables(null, null, icon, null) + binding.scheduledDateTime.setCompoundDrawablesRelative(null, null, icon, null) } fun setResetOnClickListener(listener: OnClickListener?) { 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 7cda8cc21..6c3d75c65 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 @@ -107,7 +107,7 @@ class FocusIndicatorView val imageSize = this.imageSize val focus = this.focus - if (imageSize != null && focus != null) { + if (imageSize != null && focus?.x != null && focus.y != null) { val x = axisFromFocus(focus.x, imageSize.x, this.width) val y = axisFromFocus(-focus.y, imageSize.y, this.height) val circleRadius = getCircleRadius() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt index c55e8fce7..a2fb69480 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt @@ -16,9 +16,12 @@ package com.keylesspalace.tusky.components.compose.view import android.content.Context +import android.content.res.ColorStateList import android.util.AttributeSet import android.view.LayoutInflater -import android.widget.LinearLayout +import com.google.android.material.R as materialR +import com.google.android.material.card.MaterialCardView +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.PreviewPollOptionsAdapter import com.keylesspalace.tusky.databinding.ViewPollPreviewBinding @@ -27,23 +30,18 @@ import com.keylesspalace.tusky.entity.NewPoll class PollPreviewView @JvmOverloads constructor( context: Context?, attrs: AttributeSet? = null, - defStyleAttr: Int = 0 + defStyleAttr: Int = materialR.attr.materialCardViewOutlinedStyle ) : - LinearLayout(context, attrs, defStyleAttr) { + MaterialCardView(context, attrs, defStyleAttr) { private val adapter = PreviewPollOptionsAdapter() private val binding = ViewPollPreviewBinding.inflate(LayoutInflater.from(context), this) init { - orientation = VERTICAL - - setBackgroundResource(R.drawable.card_frame) - - val padding = resources.getDimensionPixelSize(R.dimen.poll_preview_padding) - - setPadding(padding, padding, padding, padding) - + setStrokeColor(ColorStateList.valueOf(MaterialColors.getColor(this, materialR.attr.colorOutline))) + strokeWidth + elevation = 0f binding.pollPreviewOptions.adapter = adapter } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.kt index 2c812caef..8bdaa465d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.kt @@ -24,6 +24,8 @@ import android.graphics.RectF import android.util.AttributeSet import androidx.appcompat.content.res.AppCompatResources import at.connyduck.sparkbutton.helpers.Utils +import com.google.android.material.R as materialR +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.view.MediaPreviewImageView @@ -37,7 +39,7 @@ class ProgressImageView private val progressRect = RectF() private val biggerRect = RectF() private val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = context.getColor(R.color.chinwag_green) + color = MaterialColors.getColor(this@ProgressImageView, materialR.attr.colorPrimary) strokeWidth = Utils.dpToPx(context, 4).toFloat() style = Paint.Style.STROKE } @@ -46,13 +48,15 @@ class ProgressImageView } private val markBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL - color = context.getColor(R.color.tusky_grey_10) + color = MaterialColors.getColor(this@ProgressImageView, android.R.attr.colorBackground) } private val captionDrawable = AppCompatResources.getDrawable( context, R.drawable.spellcheck )!!.apply { - setTint(Color.WHITE) + setTint( + MaterialColors.getColor(this@ProgressImageView, android.R.attr.textColorTertiary) + ) } private val circleRadius = Utils.dpToPx(context, 14) private val circleMargin = Utils.dpToPx(context, 14) @@ -68,8 +72,10 @@ class ProgressImageView } fun setChecked(checked: Boolean) { - markBgPaint.color = - context.getColor(if (checked) R.color.chinwag_green else R.color.tusky_grey_10) + val backgroundColor = if (checked) materialR.attr.colorPrimary else android.R.attr.colorBackground + val foregroundColor = if (checked) materialR.attr.colorOnPrimary else android.R.attr.textColorTertiary + markBgPaint.color = MaterialColors.getColor(this, backgroundColor) + captionDrawable.setTint(MaterialColors.getColor(this, foregroundColor)) invalidate() } 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 995e400e1..beb57d941 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 @@ -38,9 +38,9 @@ class TootButton init { if (smallStyle) { setIconResource(R.drawable.ic_send_24dp) + iconPadding = 0 } else { setText(R.string.action_send) - iconGravity = ICON_GRAVITY_TEXT_START } val padding = resources.getDimensionPixelSize(R.dimen.toot_button_horizontal_padding) setPadding(padding, 0, padding, 0) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt index beca7342d..184ff1745 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt @@ -48,13 +48,9 @@ class ConversationAdapter( onBindViewHolder(holder, position, emptyList()) } - override fun onBindViewHolder( - holder: ConversationViewHolder, - position: Int, - payloads: List - ) { + override fun onBindViewHolder(holder: ConversationViewHolder, position: Int, payloads: List) { getItem(position)?.let { conversationViewData -> - holder.setupWithConversation(conversationViewData, payloads.firstOrNull()) + holder.setupWithConversation(conversationViewData, payloads) } } @@ -80,7 +76,7 @@ class ConversationAdapter( ): Any? { return if (oldItem == newItem) { // If items are equal - update timestamp only - listOf(StatusBaseViewHolder.Key.KEY_CREATED) + StatusBaseViewHolder.Key.KEY_CREATED } else { // If items are different - update the whole view holder null 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 d38898c77..e3c0d5cdf 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 @@ -126,7 +126,7 @@ data class ConversationStatusEntity( visibility = Status.Visibility.DIRECT, attachments = attachments, mentions = mentions, - tags = tags, + tags = tags.orEmpty(), application = null, pinned = false, muted = muted, @@ -148,7 +148,7 @@ fun TimelineAccount.toEntity() = ConversationAccountEntity( username = username, displayName = name, avatar = avatar, - emojis = emojis.orEmpty() + emojis = emojis ) fun Status.toEntity(expanded: Boolean, contentShowing: Boolean, contentCollapsed: Boolean) = 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 3c3103e0d..d94f60113 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 @@ -24,7 +24,6 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; @@ -69,30 +68,37 @@ public class ConversationViewHolder extends StatusBaseViewHolder { void setupWithConversation( @NonNull ConversationViewData conversation, - @Nullable Object payloads + @NonNull List payloads ) { StatusViewData.Concrete statusViewData = conversation.getLastStatus(); Status status = statusViewData.getStatus(); - if (payloads == null) { + if (payloads.isEmpty()) { TimelineAccount account = status.getAccount(); setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), status.getSpoilerText(), listener); - setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions); + String displayName = account.getDisplayName(); + if (displayName == null) { + displayName = ""; + } + setDisplayName(displayName, account.getEmojis(), statusDisplayOptions); setUsername(account.getUsername()); setMetaData(statusViewData, statusDisplayOptions, listener); - setIsReply(status.getInReplyToId() != null); setFavourited(status.getFavourited()); setBookmarked(status.getBookmarked()); List attachments = status.getAttachments(); boolean sensitive = status.getSensitive(); - if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { + if (attachments.isEmpty()) { + mediaContainer.setVisibility(View.GONE); + } else if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { + mediaContainer.setVisibility(View.VISIBLE); + setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(), statusDisplayOptions.useBlurhash()); - if (attachments.size() == 0) { + if (attachments.isEmpty()) { hideSensitiveMediaWarning(); } // Hide the unused label. @@ -100,6 +106,8 @@ public class ConversationViewHolder extends StatusBaseViewHolder { mediaLabel.setVisibility(View.GONE); } } else { + mediaContainer.setVisibility(View.VISIBLE); + setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent()); // Hide all unused views. mediaPreview.setVisibility(View.GONE); @@ -115,11 +123,9 @@ public class ConversationViewHolder extends StatusBaseViewHolder { setAvatars(conversation.getAccounts()); } else { - if (payloads instanceof List) { - for (Object item : (List) payloads) { - if (Key.KEY_CREATED.equals(item)) { - setMetaData(statusViewData, statusDisplayOptions, listener); - } + for (Object item : payloads) { + if (Key.KEY_CREATED.equals(item)) { + setMetaData(statusViewData, statusDisplayOptions, listener); } } } 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 b95276c51..9f8cd1018 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 @@ -15,98 +15,80 @@ package com.keylesspalace.tusky.components.conversation +import android.content.SharedPreferences 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 import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import at.connyduck.sparkbutton.helpers.Utils import com.google.android.material.color.MaterialColors +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity -import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.databinding.FragmentTimelineBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.fragment.SFragment -import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.CardViewMode import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.ensureBottomPadding import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.isAnyLoading import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.updateRelativeTimePeriodically 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 dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlin.time.DurationUnit -import kotlin.time.toDuration -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +@AndroidEntryPoint class ConversationsFragment : - SFragment(), + SFragment(R.layout.fragment_timeline), StatusActionListener, - Injectable, ReselectableFragment, MenuProvider { - @Inject - lateinit var viewModelFactory: ViewModelFactory - @Inject lateinit var eventHub: EventHub - private val viewModel: ConversationsViewModel by viewModels { viewModelFactory } + @Inject + lateinit var preferences: SharedPreferences + + private val viewModel: ConversationsViewModel by viewModels() private val binding by viewBinding(FragmentTimelineBinding::bind) - private lateinit var adapter: ConversationAdapter - - private var hideFab = false - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_timeline, container, false) - } + private var adapter: ConversationAdapter? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) { requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) - val statusDisplayOptions = StatusDisplayOptions( animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), - mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, + mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled != false, useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), @@ -120,11 +102,12 @@ class ConversationsFragment : openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) - adapter = ConversationAdapter(statusDisplayOptions, this) + val adapter = ConversationAdapter(statusDisplayOptions, this) + this.adapter = adapter - setupRecyclerView() + setupRecyclerView(adapter) - initSwipeToRefresh() + binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() } adapter.addLoadStateListener { loadState -> if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) { @@ -135,7 +118,7 @@ class ConversationsFragment : binding.progressBar.hide() if (loadState.isAnyLoading()) { - lifecycleScope.launch { + viewLifecycleOwner.lifecycleScope.launch { eventHub.dispatch( ConversationsLoadingEvent( accountManager.activeAccount?.accountId ?: "" @@ -174,7 +157,8 @@ class ConversationsFragment : adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - if (positionStart == 0 && adapter.itemCount != itemCount) { + val firstPos = (binding.recyclerView.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() + if (firstPos == 0 && positionStart == 0 && adapter.itemCount != itemCount) { binding.recyclerView.post { if (getView() != null) { binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) @@ -184,53 +168,29 @@ class ConversationsFragment : } }) - hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false) - binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { - val composeButton = (activity as ActionButtonActivity).actionButton - if (composeButton != null) { - if (hideFab) { - if (dy > 0 && composeButton.isShown) { - composeButton.hide() // hides the button if we're scrolling down - } else if (dy < 0 && !composeButton.isShown) { - composeButton.show() // shows it if we are scrolling up - } - } else if (!composeButton.isShown) { - composeButton.show() - } - } - } - }) - viewLifecycleOwner.lifecycleScope.launch { viewModel.conversationFlow.collectLatest { pagingData -> adapter.submitData(pagingData) } } - 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)) - } - } - } + updateRelativeTimePeriodically(preferences, adapter) - lifecycleScope.launch { + viewLifecycleOwner.lifecycleScope.launch { eventHub.events.collect { event -> if (event is PreferenceChangedEvent) { - onPreferenceChanged(event.preferenceKey) + onPreferenceChanged(adapter, event.preferenceKey) } } } } + override fun onDestroyView() { + // Clear the adapter to prevent leaking the View + adapter = null + super.onDestroyView() + } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.fragment_conversations, menu) menu.findItem(R.id.action_refresh)?.apply { @@ -253,7 +213,8 @@ class ConversationsFragment : } } - private fun setupRecyclerView() { + private fun setupRecyclerView(adapter: ConversationAdapter) { + binding.recyclerView.ensureBottomPadding(fab = true) binding.recyclerView.setHasFixedSize(true) binding.recyclerView.layoutManager = LinearLayoutManager(context) @@ -268,26 +229,21 @@ class ConversationsFragment : } private fun refreshContent() { - adapter.refresh() + adapter?.refresh() } - private fun initSwipeToRefresh() { - binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() } - binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) - } - - override fun onReblog(reblog: Boolean, position: Int) { + override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) { // its impossible to reblog private messages } override fun onFavourite(favourite: Boolean, position: Int) { - adapter.peek(position)?.let { conversation -> + adapter?.peek(position)?.let { conversation -> viewModel.favourite(favourite, conversation) } } override fun onBookmark(favourite: Boolean, position: Int) { - adapter.peek(position)?.let { conversation -> + adapter?.peek(position)?.let { conversation -> viewModel.bookmark(favourite, conversation) } } @@ -295,7 +251,7 @@ class ConversationsFragment : override val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit)? = null override fun onMore(view: View, position: Int) { - adapter.peek(position)?.let { conversation -> + adapter?.peek(position)?.let { conversation -> val popup = PopupMenu(requireContext(), view) popup.inflate(R.menu.conversation_more) @@ -319,17 +275,17 @@ class ConversationsFragment : } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - adapter.peek(position)?.let { conversation -> + adapter?.peek(position)?.let { conversation -> viewMedia( attachmentIndex, - AttachmentViewData.list(conversation.lastStatus.status), + AttachmentViewData.list(conversation.lastStatus), view ) } } override fun onViewThread(position: Int) { - adapter.peek(position)?.let { conversation -> + adapter?.peek(position)?.let { conversation -> viewThread(conversation.lastStatus.id, conversation.lastStatus.status.url) } } @@ -339,13 +295,13 @@ class ConversationsFragment : } override fun onExpandedChange(expanded: Boolean, position: Int) { - adapter.peek(position)?.let { conversation -> + adapter?.peek(position)?.let { conversation -> viewModel.expandHiddenStatus(expanded, conversation) } } override fun onContentHiddenChange(isShowing: Boolean, position: Int) { - adapter.peek(position)?.let { conversation -> + adapter?.peek(position)?.let { conversation -> viewModel.showContent(isShowing, conversation) } } @@ -355,7 +311,7 @@ class ConversationsFragment : } override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - adapter.peek(position)?.let { conversation -> + adapter?.peek(position)?.let { conversation -> viewModel.collapseLongStatus(isCollapsed, conversation) } } @@ -375,13 +331,13 @@ class ConversationsFragment : } override fun onReply(position: Int) { - adapter.peek(position)?.let { conversation -> + adapter?.peek(position)?.let { conversation -> reply(conversation.lastStatus.status) } } - override fun onVoteInPoll(position: Int, choices: MutableList) { - adapter.peek(position)?.let { conversation -> + override fun onVoteInPoll(position: Int, choices: List) { + adapter?.peek(position)?.let { conversation -> viewModel.voteInPoll(choices, conversation) } } @@ -390,7 +346,7 @@ class ConversationsFragment : } override fun onReselect() { - if (isAdded) { + if (view != null) { binding.recyclerView.layoutManager?.scrollToPosition(0) binding.recyclerView.stopScroll() } @@ -401,7 +357,7 @@ class ConversationsFragment : } private fun deleteConversation(conversation: ConversationViewData) { - AlertDialog.Builder(requireContext()) + MaterialAlertDialogBuilder(requireContext()) .setMessage(R.string.dialog_delete_conversation_warning) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(android.R.string.ok) { _, _ -> @@ -410,13 +366,8 @@ class ConversationsFragment : .show() } - private fun onPreferenceChanged(key: String) { - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + private fun onPreferenceChanged(adapter: ConversationAdapter, key: String) { when (key) { - PrefKeys.FAB_HIDE -> { - hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) - } - PrefKeys.MEDIA_PREVIEW_ENABLED -> { val enabled = accountManager.activeAccount!!.mediaPreviewEnabled val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled 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 cf81e5ef6..fed00b346 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 @@ -5,7 +5,6 @@ import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import androidx.room.withTransaction -import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.HttpHeaderLink @@ -15,19 +14,22 @@ import retrofit2.HttpException class ConversationsRemoteMediator( private val api: MastodonApi, private val db: AppDatabase, - accountManager: AccountManager + private val viewModel: ConversationsViewModel ) : RemoteMediator() { private var nextKey: String? = null private var order: Int = 0 - private val activeAccount = accountManager.activeAccount!! - override suspend fun load( loadType: LoadType, state: PagingState ): MediatorResult { + val activeAccount = viewModel.activeAccountFlow.value + if (activeAccount == null) { + return MediatorResult.Success(endOfPaginationReached = true) + } + if (loadType == LoadType.PREPEND) { return MediatorResult.Success(endOfPaginationReached = true) } 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 2972293ae..5e1fa6dbe 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 @@ -28,29 +28,30 @@ import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases -import com.keylesspalace.tusky.util.EmptyPagingSource +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +@HiltViewModel class ConversationsViewModel @Inject constructor( private val timelineCases: TimelineCases, private val database: AppDatabase, - private val accountManager: AccountManager, - private val api: MastodonApi + private val api: MastodonApi, + accountManager: AccountManager ) : ViewModel() { + val activeAccountFlow = accountManager.activeAccount(viewModelScope) + private val accountId: Long = activeAccountFlow.value!!.id + @OptIn(ExperimentalPagingApi::class) val conversationFlow = Pager( - config = PagingConfig(pageSize = 30), - remoteMediator = ConversationsRemoteMediator(api, database, accountManager), + config = PagingConfig( + pageSize = 30 + ), + remoteMediator = ConversationsRemoteMediator(api, database, this), pagingSourceFactory = { - val activeAccount = accountManager.activeAccount - if (activeAccount == null) { - EmptyPagingSource() - } else { - database.conversationDao().conversationsForAccount(activeAccount.id) - } + database.conversationDao().conversationsForAccount(accountId) } ) .flow @@ -63,7 +64,7 @@ class ConversationsViewModel @Inject constructor( viewModelScope.launch { timelineCases.favourite(conversation.lastStatus.id, favourite).fold({ val newConversation = conversation.toEntity( - accountId = accountManager.activeAccount!!.id, + accountId = accountId, favourited = favourite ) @@ -78,7 +79,7 @@ class ConversationsViewModel @Inject constructor( viewModelScope.launch { timelineCases.bookmark(conversation.lastStatus.id, bookmark).fold({ val newConversation = conversation.toEntity( - accountId = accountManager.activeAccount!!.id, + accountId = accountId, bookmarked = bookmark ) @@ -98,7 +99,7 @@ class ConversationsViewModel @Inject constructor( ) .fold({ poll -> val newConversation = conversation.toEntity( - accountId = accountManager.activeAccount!!.id, + accountId = accountId, poll = poll ) @@ -112,7 +113,7 @@ class ConversationsViewModel @Inject constructor( fun expandHiddenStatus(expanded: Boolean, conversation: ConversationViewData) { viewModelScope.launch { val newConversation = conversation.toEntity( - accountId = accountManager.activeAccount!!.id, + accountId = accountId, expanded = expanded ) saveConversationToDb(newConversation) @@ -122,7 +123,7 @@ class ConversationsViewModel @Inject constructor( fun collapseLongStatus(collapsed: Boolean, conversation: ConversationViewData) { viewModelScope.launch { val newConversation = conversation.toEntity( - accountId = accountManager.activeAccount!!.id, + accountId = accountId, collapsed = collapsed ) saveConversationToDb(newConversation) @@ -132,7 +133,7 @@ class ConversationsViewModel @Inject constructor( fun showContent(showing: Boolean, conversation: ConversationViewData) { viewModelScope.launch { val newConversation = conversation.toEntity( - accountId = accountManager.activeAccount!!.id, + accountId = accountId, showingHiddenContent = showing ) saveConversationToDb(newConversation) @@ -146,7 +147,7 @@ class ConversationsViewModel @Inject constructor( database.conversationDao().delete( id = conversation.id, - accountId = accountManager.activeAccount!!.id + accountId = accountId ) } catch (e: Exception) { Log.w(TAG, "failed to delete conversation", e) @@ -163,7 +164,7 @@ class ConversationsViewModel @Inject constructor( ) val newConversation = conversation.toEntity( - accountId = accountManager.activeAccount!!.id, + accountId = accountId, muted = !conversation.lastStatus.status.muted ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksActivity.kt index 618174907..3a1ae69a0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksActivity.kt @@ -4,14 +4,10 @@ import android.os.Bundle import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ActivityAccountListBinding -import dagger.android.DispatchingAndroidInjector -import dagger.android.HasAndroidInjector -import javax.inject.Inject +import dagger.hilt.android.AndroidEntryPoint -class DomainBlocksActivity : BaseActivity(), HasAndroidInjector { - - @Inject - lateinit var androidInjector: DispatchingAndroidInjector +@AndroidEntryPoint +class DomainBlocksActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -30,6 +26,4 @@ class DomainBlocksActivity : BaseActivity(), HasAndroidInjector { .replace(R.id.fragment_container, DomainBlocksFragment()) .commit() } - - override fun androidInjector() = androidInjector } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksFragment.kt index 1adfb911c..0978e06a4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksFragment.kt @@ -12,28 +12,27 @@ import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.FragmentDomainBlocksBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.util.ensureBottomPadding 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 javax.inject.Inject +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -class DomainBlocksFragment : Fragment(R.layout.fragment_domain_blocks), Injectable { - - @Inject - lateinit var viewModelFactory: ViewModelFactory +@AndroidEntryPoint +class DomainBlocksFragment : Fragment(R.layout.fragment_domain_blocks) { private val binding by viewBinding(FragmentDomainBlocksBinding::bind) - private val viewModel: DomainBlocksViewModel by viewModels { viewModelFactory } + private val viewModel: DomainBlocksViewModel by viewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val adapter = DomainBlocksAdapter(viewModel::unblock) + binding.recyclerView.ensureBottomPadding() + binding.recyclerView.setHasFixedSize(true) binding.recyclerView.addItemDecoration( DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL) @@ -47,7 +46,7 @@ class DomainBlocksFragment : Fragment(R.layout.fragment_domain_blocks), Injectab } } - lifecycleScope.launch { + viewLifecycleOwner.lifecycleScope.launch { viewModel.domainPager.collectLatest { pagingData -> adapter.submitData(pagingData) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksRepository.kt index bdc9b9367..c1c993d3d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksRepository.kt @@ -39,7 +39,10 @@ class DomainBlocksRepository @Inject constructor( @OptIn(ExperimentalPagingApi::class) val domainPager = Pager( - config = PagingConfig(pageSize = PAGE_SIZE, initialLoadSize = PAGE_SIZE), + config = PagingConfig( + pageSize = PAGE_SIZE, + initialLoadSize = PAGE_SIZE + ), remoteMediator = DomainBlocksRemoteMediator(api, this), pagingSourceFactory = factory ).flow diff --git a/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksViewModel.kt index aa316c651..a07cd84bc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksViewModel.kt @@ -8,12 +8,14 @@ import androidx.paging.cachedIn import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.onFailure import com.keylesspalace.tusky.R +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch +@HiltViewModel class DomainBlocksViewModel @Inject constructor( private val repo: DomainBlocksRepository ) : ViewModel() { 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 7cfed4ea8..742f1adaf 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 @@ -23,12 +23,13 @@ import androidx.core.content.FileProvider import androidx.core.net.toUri import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.db.DraftAttachment -import com.keylesspalace.tusky.db.DraftEntity +import com.keylesspalace.tusky.db.entity.DraftAttachment +import com.keylesspalace.tusky.db.entity.DraftEntity import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.util.copyToFile +import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import java.io.IOException import java.text.SimpleDateFormat @@ -43,7 +44,7 @@ import okio.buffer import okio.sink class DraftHelper @Inject constructor( - val context: Context, + @ApplicationContext val context: Context, private val okHttpClient: OkHttpClient, db: AppDatabase ) { @@ -177,7 +178,7 @@ class DraftHelper @Inject constructor( map.getExtensionFromMimeType(mimeType) } - val filename = String.format("Tusky_Draft_Media_%s_%d.%s", timeStamp, index, fileExtension) + val filename = "Tusky_Draft_Media_${timeStamp}_$index.$fileExtension" val file = File(folder, filename) if (scheme == "https") { 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 fd6816b7b..ac631721d 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 @@ -24,7 +24,7 @@ import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.db.DraftAttachment +import com.keylesspalace.tusky.db.entity.DraftAttachment import com.keylesspalace.tusky.view.MediaPreviewImageView class DraftMediaAdapter( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt index 1db7982c6..095bd9469 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt @@ -32,25 +32,24 @@ import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.databinding.ActivityDraftsBinding -import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.db.DraftsAlert -import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.db.entity.DraftEntity +import com.keylesspalace.tusky.util.ensureBottomPadding import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.visible +import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +@AndroidEntryPoint class DraftsActivity : BaseActivity(), DraftActionListener { - @Inject - lateinit var viewModelFactory: ViewModelFactory - @Inject lateinit var draftsAlert: DraftsAlert - private val viewModel: DraftsViewModel by viewModels { viewModelFactory } + private val viewModel: DraftsViewModel by viewModels() private lateinit var binding: ActivityDraftsBinding private lateinit var bottomSheet: BottomSheetBehavior @@ -68,6 +67,8 @@ class DraftsActivity : BaseActivity(), DraftActionListener { setDisplayShowHomeEnabled(true) } + binding.draftsRecyclerView.ensureBottomPadding() + binding.draftsErrorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_drafts) val adapter = DraftsAdapter(this) 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 1c8ddbfed..05d8b826c 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 @@ -22,7 +22,7 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.databinding.ItemDraftBinding -import com.keylesspalace.tusky.db.DraftEntity +import com.keylesspalace.tusky.db.entity.DraftEntity import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show 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 813a424fa..b56985cb6 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 @@ -23,12 +23,14 @@ import androidx.paging.cachedIn import at.connyduck.calladapter.networkresult.NetworkResult import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.db.DraftEntity +import com.keylesspalace.tusky.db.entity.DraftEntity import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.launch +@HiltViewModel class DraftsViewModel @Inject constructor( val database: AppDatabase, val accountManager: AccountManager, @@ -37,7 +39,9 @@ class DraftsViewModel @Inject constructor( ) : ViewModel() { val drafts = Pager( - config = PagingConfig(pageSize = 20), + config = PagingConfig( + pageSize = 20 + ), pagingSourceFactory = { database.draftDao().draftsPagingSource( accountManager.activeAccount?.id!! 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 index 709e2c5f7..81a5d5e15 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt @@ -1,38 +1,54 @@ +/* Copyright 2024 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.filters -import android.content.Context import android.content.DialogInterface.BUTTON_POSITIVE import android.os.Bundle -import android.view.View +import android.view.WindowManager import android.widget.AdapterView -import android.widget.ArrayAdapter import androidx.activity.viewModels -import androidx.appcompat.app.AlertDialog -import androidx.core.content.IntentCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.size +import androidx.core.view.updatePadding import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.lifecycleScope import at.connyduck.calladapter.networkresult.fold import com.google.android.material.chip.Chip +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.materialswitch.MaterialSwitch 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.appstore.FilterUpdatedEvent 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.getParcelableExtraCompat import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible -import java.util.Date +import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.launch +@AndroidEntryPoint class EditFilterActivity : BaseActivity() { @Inject lateinit var api: MastodonApi @@ -40,20 +56,17 @@ class EditFilterActivity : BaseActivity() { @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 val viewModel: EditFilterViewModel by viewModels() private lateinit var filter: Filter private var originalFilter: Filter? = null - private lateinit var contextSwitches: Map + private lateinit var contextSwitches: Map override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - originalFilter = IntentCompat.getParcelableExtra(intent, FILTER_TO_EDIT, Filter::class.java) + originalFilter = intent.getParcelableExtraCompat(FILTER_TO_EDIT) filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN.action, listOf()) binding.apply { contextSwitches = mapOf( @@ -81,6 +94,12 @@ class EditFilterActivity : BaseActivity() { } ) + ViewCompat.setOnApplyWindowInsetsListener(binding.scrollView) { scrollView, insets -> + val systemBarsInsets = insets.getInsets(systemBars()) + scrollView.updatePadding(bottom = systemBarsInsets.bottom) + insets.inset(0, 0, 0, systemBarsInsets.bottom) + } + binding.actionChip.setOnClickListener { showAddKeywordDialog() } binding.filterSaveButton.setOnClickListener { saveChanges() } binding.filterDeleteButton.setOnClickListener { @@ -114,30 +133,20 @@ class EditFilterActivity : BaseActivity() { } ) } - 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) - } + binding.filterDurationDropDown.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> + viewModel.setDuration( + if (originalFilter?.expiresAt == null) { + position + } else { + position - 1 + } + ) } validateSaveButton() if (originalFilter == null) { binding.filterActionWarn.isChecked = true + initializeDurationDropDown(false) } else { loadFilter() } @@ -179,10 +188,17 @@ class EditFilterActivity : BaseActivity() { // 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) + initializeDurationDropDown(withNoChange = filter.expiresAt != null) + } + + private fun initializeDurationDropDown(withNoChange: Boolean) { + val durationNames = if (withNoChange) { + arrayOf(getString(R.string.duration_no_change)) + resources.getStringArray(R.array.filter_duration_names) + } else { + resources.getStringArray(R.array.filter_duration_names) } + binding.filterDurationDropDown.setSimpleItems(durationNames) + binding.filterDurationDropDown.setText(durationNames[0], false) } private fun updateKeywords(newKeywords: List) { @@ -223,7 +239,7 @@ class EditFilterActivity : BaseActivity() { private fun showAddKeywordDialog() { val binding = DialogFilterBinding.inflate(layoutInflater) binding.phraseWholeWord.isChecked = true - AlertDialog.Builder(this) + val dialog = MaterialAlertDialogBuilder(this) .setTitle(R.string.filter_keyword_addition_title) .setView(binding.root) .setPositiveButton(android.R.string.ok) { _, _ -> @@ -237,6 +253,12 @@ class EditFilterActivity : BaseActivity() { } .setNegativeButton(android.R.string.cancel, null) .show() + + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + + val editText = binding.phraseEditText + editText.requestFocus() + editText.setSelection(editText.length()) } private fun showEditKeywordDialog(keyword: FilterKeyword) { @@ -244,7 +266,7 @@ class EditFilterActivity : BaseActivity() { binding.phraseEditText.setText(keyword.keyword) binding.phraseWholeWord.isChecked = keyword.wholeWord - AlertDialog.Builder(this) + val dialog = MaterialAlertDialogBuilder(this) .setTitle(R.string.filter_edit_keyword_title) .setView(binding.root) .setPositiveButton(R.string.filter_dialog_update_button) { _, _ -> @@ -258,6 +280,12 @@ class EditFilterActivity : BaseActivity() { } .setNegativeButton(android.R.string.cancel, null) .show() + + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + + val editText = binding.phraseEditText + editText.requestFocus() + editText.setSelection(editText.length()) } private fun validateSaveButton() { @@ -278,7 +306,7 @@ class EditFilterActivity : BaseActivity() { } else { Snackbar.make( binding.root, - "Error saving filter '${viewModel.title.value}'", + getString(R.string.error_deleting_filter, viewModel.title.value), Snackbar.LENGTH_SHORT ).show() } @@ -301,7 +329,7 @@ class EditFilterActivity : BaseActivity() { { Snackbar.make( binding.root, - "Error deleting filter '${filter.title}'", + getString(R.string.error_deleting_filter, filter.title), Snackbar.LENGTH_SHORT ).show() } @@ -309,7 +337,7 @@ class EditFilterActivity : BaseActivity() { } else { Snackbar.make( binding.root, - "Error deleting filter '${filter.title}'", + getString(R.string.error_deleting_filter, filter.title), Snackbar.LENGTH_SHORT ).show() } @@ -321,19 +349,5 @@ class EditFilterActivity : BaseActivity() { 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 index 83c8dacf1..881fd70e9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt @@ -1,21 +1,38 @@ +/* Copyright 2024 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.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.R import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterKeyword import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.isHttpNotFound +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext -class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub: EventHub) : ViewModel() { +@HiltViewModel +class EditFilterViewModel @Inject constructor(val api: MastodonApi) : ViewModel() { private var originalFilter: Filter? = null private val _title = MutableStateFlow("") @@ -111,12 +128,12 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub durationIndex: Int, context: Context ): Boolean { - val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context) + val expiration = getExpirationForDurationIndex(durationIndex, context) api.createFilter( title = title, context = contexts, filterAction = action, - expiresInSeconds = expiresInSeconds + expiresIn = expiration ).fold( { newFilter -> // This is _terrible_, but the all-in-one update filter api Just Doesn't Work @@ -132,7 +149,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub return ( throwable.isHttpNotFound() && // Endpoint not found, fall back to v1 api - createFilterV1(contexts, expiresInSeconds) + createFilterV1(contexts, expiration) ) } ) @@ -146,13 +163,13 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub durationIndex: Int, context: Context ): Boolean { - val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context) + val expiration = getExpirationForDurationIndex(durationIndex, context) api.updateFilter( id = originalFilter.id, title = title, context = contexts, filterAction = action, - expiresInSeconds = expiresInSeconds + expires = expiration ).fold( { // This is _terrible_, but the all-in-one update filter api Just Doesn't Work @@ -172,7 +189,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub { throwable -> if (throwable.isHttpNotFound()) { // Endpoint not found, fall back to v1 api - if (updateFilterV1(contexts, expiresInSeconds)) { + if (updateFilterV1(contexts, expiration)) { return true } } @@ -181,13 +198,13 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub ) } - private suspend fun createFilterV1(context: List, expiresInSeconds: Int?): Boolean { + private suspend fun createFilterV1(context: List, expiration: FilterExpiration?): Boolean { return _keywords.value.map { keyword -> - api.createFilterV1(keyword.keyword, context, false, keyword.wholeWord, expiresInSeconds) + api.createFilterV1(keyword.keyword, context, false, keyword.wholeWord, expiration) }.none { it.isFailure } } - private suspend fun updateFilterV1(context: List, expiresInSeconds: Int?): Boolean { + private suspend fun updateFilterV1(context: List, expiration: FilterExpiration?): Boolean { val results = _keywords.value.map { keyword -> if (originalFilter == null) { api.createFilterV1( @@ -195,7 +212,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub context = context, irreversible = false, wholeWord = keyword.wholeWord, - expiresInSeconds = expiresInSeconds + expiresIn = expiration ) } else { api.updateFilterV1( @@ -204,7 +221,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub context = context, irreversible = false, wholeWord = keyword.wholeWord, - expiresInSeconds = expiresInSeconds + expiresIn = expiration ) } } @@ -212,4 +229,18 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub return results.none { it.isFailure } } + + companion object { + // Mastodon *stores* the absolute date in the filter, + // but create/edit take a number of seconds (relative to the time the operation is posted) + private fun getExpirationForDurationIndex(index: Int, context: Context): FilterExpiration? { + return when (index) { + -1 -> FilterExpiration.unchanged + 0 -> FilterExpiration.never + else -> FilterExpiration.seconds( + context.resources.getIntArray(R.array.filter_duration_values)[index] + ) + } + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FilterExpiration.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FilterExpiration.kt new file mode 100644 index 000000000..27d1290d4 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FilterExpiration.kt @@ -0,0 +1,37 @@ +/* Copyright 2024 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.filters + +import kotlin.jvm.JvmInline + +/** + * Custom class to have typesafety for filter expirations. + * Retrofit will call toString when sending this class as part of a form-urlencoded body. + */ +@JvmInline +value class FilterExpiration private constructor(val seconds: Int) { + + override fun toString(): String { + return if (seconds < 0) "" else seconds.toString() + } + + companion object { + val unchanged: FilterExpiration? = null + val never: FilterExpiration = FilterExpiration(-1) + + fun seconds(seconds: Int): FilterExpiration = FilterExpiration(seconds) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FilterExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FilterExtensions.kt index 0f14bc5fd..e2c0b589b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/FilterExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FilterExtensions.kt @@ -18,14 +18,14 @@ package com.keylesspalace.tusky.components.filters import android.app.Activity -import androidx.appcompat.app.AlertDialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.R import com.keylesspalace.tusky.util.await -internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = AlertDialog.Builder( - this -) - .setMessage(getString(R.string.dialog_delete_filter_text, filterTitle)) - .setCancelable(true) - .create() - .await(R.string.dialog_delete_filter_positive_action, android.R.string.cancel) +internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String): Int { + return MaterialAlertDialogBuilder(this) + .setMessage(getString(R.string.dialog_delete_filter_text, filterTitle)) + .setCancelable(true) + .create() + .await(R.string.dialog_delete_filter_positive_action, android.R.string.cancel) +} 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 index f6ff1c4b7..c4087fd57 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt @@ -3,27 +3,36 @@ package com.keylesspalace.tusky.components.filters import android.content.DialogInterface.BUTTON_POSITIVE import android.content.Intent import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.DividerItemDecoration 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.ensureBottomMargin +import com.keylesspalace.tusky.util.ensureBottomPadding import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.launchAndRepeatOnLifecycle import com.keylesspalace.tusky.util.show -import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible -import javax.inject.Inject +import com.keylesspalace.tusky.util.withSlideInAnimation +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +@AndroidEntryPoint class FiltersActivity : BaseActivity(), FiltersListener { - @Inject - lateinit var viewModelFactory: ViewModelFactory private val binding by viewBinding(ActivityFiltersBinding::inflate) - private val viewModel: FiltersViewModel by viewModels { viewModelFactory } + private val viewModel: FiltersViewModel by viewModels() + + private val editFilterLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + // refresh the filters upon returning from EditFilterActivity + reloadFilters() + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -36,24 +45,26 @@ class FiltersActivity : BaseActivity(), FiltersListener { setDisplayShowHomeEnabled(true) } + binding.filtersList.ensureBottomPadding(fab = true) + binding.addFilterButton.ensureBottomMargin() + binding.addFilterButton.setOnClickListener { launchEditFilterActivity() } - binding.swipeRefreshLayout.setOnRefreshListener { loadFilters() } - binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) + binding.swipeRefreshLayout.setOnRefreshListener { reloadFilters() } setTitle(R.string.pref_title_timeline_filters) - } - override fun onResume() { - super.onResume() - loadFilters() + binding.filtersList.addItemDecoration( + DividerItemDecoration(this, DividerItemDecoration.VERTICAL) + ) + observeViewModel() } private fun observeViewModel() { - lifecycleScope.launch { + launchAndRepeatOnLifecycle { viewModel.state.collect { state -> binding.progressBar.visible( state.loadingState == FiltersViewModel.LoadingState.LOADING @@ -70,7 +81,7 @@ class FiltersActivity : BaseActivity(), FiltersListener { R.drawable.errorphant_offline, R.string.error_network ) { - loadFilters() + reloadFilters() } binding.messageView.show() } @@ -79,7 +90,7 @@ class FiltersActivity : BaseActivity(), FiltersListener { R.drawable.errorphant_error, R.string.error_generic ) { - loadFilters() + reloadFilters() } binding.messageView.show() } @@ -101,8 +112,8 @@ class FiltersActivity : BaseActivity(), FiltersListener { } } - private fun loadFilters() { - viewModel.load() + private fun reloadFilters() { + viewModel.reload() } private fun launchEditFilterActivity(filter: Filter? = null) { @@ -110,8 +121,8 @@ class FiltersActivity : BaseActivity(), FiltersListener { if (filter != null) { putExtra(EditFilterActivity.FILTER_TO_EDIT, filter) } - } - startActivityWithSlideInAnimation(intent) + }.withSlideInAnimation() + editFilterLauncher.launch(intent) } override fun deleteFilter(filter: 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 index 0f1a5a2ac..115a7d95b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt @@ -1,21 +1,28 @@ package com.keylesspalace.tusky.components.filters +import android.util.Log 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.R import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.appstore.FilterUpdatedEvent import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.isHttpNotFound +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +@HiltViewModel class FiltersViewModel @Inject constructor( private val api: MastodonApi, private val eventHub: EventHub @@ -34,76 +41,93 @@ class FiltersViewModel @Inject constructor( private val _state = MutableStateFlow(State(emptyList(), LoadingState.INITIAL)) val state: StateFlow = _state.asStateFlow() - fun load() { - this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.LOADING) + private val loadTrigger = MutableStateFlow(0) + init { viewModelScope.launch { + observeLoad() + } + } + + private suspend fun observeLoad() { + loadTrigger.collectLatest { + _state.update { it.copy(loadingState = LoadingState.LOADING) } + api.getFilters().fold( { filters -> - this@FiltersViewModel._state.value = State(filters, LoadingState.LOADED) + _state.value = State(filters, LoadingState.LOADED) }, { throwable -> if (throwable.isHttpNotFound()) { + Log.i(TAG, "failed loading filters v2, falling back to v1", throwable) + api.getFiltersV1().fold( { filters -> - this@FiltersViewModel._state.value = State(filters.map { it.toFilter() }, LoadingState.LOADED) + _state.value = State(filters.map { it.toFilter() }, LoadingState.LOADED) }, - { _ -> - // TODO log errors (also below) - this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER) + { t -> + Log.w(TAG, "failed loading filters v1", t) + _state.value = State(emptyList(), 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) + Log.w(TAG, "failed loading filters v2", throwable) + _state.update { it.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 - }, + fun reload() { + loadTrigger.update { it + 1 } + } + + suspend fun deleteFilter(filter: Filter, parent: View) { + // First wait for a pending loading operation to complete + _state.first { it.loadingState > LoadingState.LOADING } + + api.deleteFilter(filter.id).fold( + { + _state.update { currentState -> + State( + currentState.filters.filter { it.id != filter.id }, LoadingState.LOADED ) - for (context in filter.context) { - eventHub.dispatch(PreferenceChangedEvent(context)) - } - }, - { throwable -> - if (throwable.isHttpNotFound()) { - api.deleteFilterV1(filter.id).fold( - { - this@FiltersViewModel._state.value = State( - this@FiltersViewModel._state.value.filters.filter { - it.id != filter.id - }, + } + eventHub.dispatch(FilterUpdatedEvent(filter.context)) + }, + { throwable -> + if (throwable.isHttpNotFound()) { + api.deleteFilterV1(filter.id).fold( + { + _state.update { currentState -> + State( + currentState.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() - } + }, + { + Snackbar.make( + parent, + parent.context.getString(R.string.error_deleting_filter, filter.title), + Snackbar.LENGTH_SHORT + ).show() + } + ) + } else { + Snackbar.make( + parent, + parent.context.getString(R.string.error_deleting_filter, filter.title), + Snackbar.LENGTH_SHORT + ).show() } - ) - } + } + ) + } + + companion object { + private const val TAG = "FiltersViewModel" } } 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 e250dc989..c3920e801 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,53 +1,48 @@ 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.StatusListActivity 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.copyToClipboard +import com.keylesspalace.tusky.util.ensureBottomMargin +import com.keylesspalace.tusky.util.ensureBottomPadding 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 com.keylesspalace.tusky.view.showHashtagPickerDialog +import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +@AndroidEntryPoint class FollowedTagsActivity : BaseActivity(), - HashtagActionListener, - ComposeAutoCompleteAdapter.AutocompletionProvider { - @Inject - lateinit var api: MastodonApi + HashtagActionListener { @Inject - lateinit var viewModelFactory: ViewModelFactory + lateinit var api: MastodonApi @Inject lateinit var sharedPreferences: SharedPreferences private val binding by viewBinding(ActivityFollowedTagsBinding::inflate) - private val viewModel: FollowedTagsViewModel by viewModels { viewModelFactory } + private val viewModel: FollowedTagsViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -61,9 +56,11 @@ class FollowedTagsActivity : setDisplayShowHomeEnabled(true) } + binding.fab.ensureBottomMargin() + binding.followedTagsView.ensureBottomPadding(fab = true) + binding.fab.setOnClickListener { - val dialog: DialogFragment = FollowTagDialog.newInstance() - dialog.show(supportFragmentManager, "dialog") + showDialog() } setupAdapter().let { adapter -> @@ -85,19 +82,6 @@ class FollowedTagsActivity : 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 { @@ -123,7 +107,7 @@ class FollowedTagsActivity : private fun follow(tagName: String, position: Int = -1) { lifecycleScope.launch { - api.followTag(tagName).fold( + val snackbarText = api.followTag(tagName).fold( { if (position == -1) { viewModel.tags.add(it) @@ -131,17 +115,20 @@ class FollowedTagsActivity : viewModel.tags.add(position, it) } viewModel.currentSource?.invalidate() + getString(R.string.follow_hashtag_success, tagName) }, - { - Snackbar.make( - this@FollowedTagsActivity, - binding.followedTagsView, - getString(R.string.error_following_hashtag_format, tagName), - Snackbar.LENGTH_SHORT - ) - .show() + { t -> + Log.w(TAG, "failed to follow hashtag $tagName", t) + getString(R.string.error_following_hashtag_format, tagName) } ) + Snackbar.make( + this@FollowedTagsActivity, + binding.followedTagsView, + snackbarText, + Snackbar.LENGTH_SHORT + ) + .show() } } @@ -178,41 +165,24 @@ class FollowedTagsActivity : } } - override fun search(token: String): List { - return viewModel.searchAutocompleteSuggestions(token) + override fun viewTag(tagName: String) { + startActivity(StatusListActivity.newHashtagIntent(this, tagName)) + } + + override fun copyTagName(tagName: String) { + copyToClipboard( + "#$tagName", + getString(R.string.confirmation_hashtag_copied, tagName), + ) + } + + private fun showDialog() { + showHashtagPickerDialog(api, R.string.dialog_follow_hashtag_title) { hashtag -> + follow(hashtag) + } } 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 211be5630..5df59a5a1 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 @@ -27,7 +27,17 @@ class FollowedTagsAdapter( position: Int ) { viewModel.tags[position].let { tag -> - holder.itemView.findViewById(R.id.followed_tag).text = tag.name + holder.itemView.findViewById(R.id.followed_tag).apply { + text = tag.name + setOnClickListener { + actionListener.viewTag(tag.name) + } + setOnLongClickListener { + actionListener.copyTagName(tag.name) + true + } + } + holder.itemView.findViewById( R.id.followed_tag_unfollow ).setOnClickListener { 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 f3376c13b..590036cb0 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,31 +1,29 @@ 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 dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -import kotlinx.coroutines.runBlocking +@HiltViewModel class FollowedTagsViewModel @Inject constructor( - private val api: MastodonApi -) : ViewModel(), Injectable { + val api: MastodonApi +) : ViewModel() { val tags: MutableList = mutableListOf() var nextKey: String? = null var currentSource: FollowedTagsPagingSource? = null @OptIn(ExperimentalPagingApi::class) val pager = Pager( - config = PagingConfig(pageSize = 100), + config = PagingConfig( + pageSize = 100 + ), remoteMediator = FollowedTagsRemoteMediator(api, this), pagingSourceFactory = { FollowedTagsPagingSource( @@ -36,24 +34,6 @@ class FollowedTagsViewModel @Inject constructor( } ).flow.cachedIn(viewModelScope) - fun searchAutocompleteSuggestions( - token: String - ): List { - return runBlocking { - api.search(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/instanceinfo/InstanceInfoRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt index cd73d3f21..171a0f31b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt @@ -17,6 +17,7 @@ package com.keylesspalace.tusky.components.instanceinfo import android.util.Log import at.connyduck.calladapter.networkresult.NetworkResult +import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrThrow import at.connyduck.calladapter.networkresult.map @@ -24,8 +25,8 @@ import at.connyduck.calladapter.networkresult.onSuccess import at.connyduck.calladapter.networkresult.recoverCatching import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.db.EmojisEntity -import com.keylesspalace.tusky.db.InstanceInfoEntity +import com.keylesspalace.tusky.db.entity.EmojisEntity +import com.keylesspalace.tusky.db.entity.InstanceInfoEntity import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Instance @@ -34,13 +35,11 @@ import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.isHttpNotFound import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject -import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -@Singleton class InstanceInfoRepository @Inject constructor( private val api: MastodonApi, db: AppDatabase, @@ -52,9 +51,6 @@ class InstanceInfoRepository @Inject constructor( private val instanceName get() = accountManager.activeAccount!!.domain - /** In-memory cache for instance data, per instance domain. */ - private var instanceInfoCache = ConcurrentHashMap() - fun precache() { // We are avoiding some duplicate work but we are not trying too hard. // We might request it multiple times in parallel which is not a big problem. @@ -65,9 +61,11 @@ class InstanceInfoRepository @Inject constructor( // - caching default value (we want to rather re-fetch if it fails) if (instanceInfoCache[instanceName] == null) { externalScope.launch { - fetchAndPersistInstanceInfo().onSuccess { fetched -> - instanceInfoCache[fetched.instance] = fetched.toInfoOrDefault() - } + fetchAndPersistInstanceInfo().fold({ fetched -> + instanceInfoCache[instanceName] = fetched.toInfoOrDefault() + }, { e -> + Log.w(TAG, "failed to precache instance info", e) + }) } } } @@ -107,6 +105,10 @@ class InstanceInfoRepository @Inject constructor( } }.toInfoOrDefault() + suspend fun saveFilterV2Support(filterV2Supported: Boolean) = dao.setFilterV2Support(instanceName, filterV2Supported) + + suspend fun isFilterV2Supported(): Boolean = dao.getFilterV2Support(instanceName) + private suspend fun InstanceInfoRepository.fetchAndPersistInstanceInfo(): NetworkResult = fetchRemoteInstanceInfo() .onSuccess { instanceInfoEntity -> @@ -168,7 +170,7 @@ class InstanceInfoRepository @Inject constructor( ?: DEFAULT_IMAGE_MATRIX_LIMIT, maxMediaAttachments = this.configuration?.statuses?.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS, - maxFields = this.pleroma?.metadata?.fieldLimits?.maxFields, + maxFields = this.configuration?.accounts?.maxProfileFields ?: this.pleroma?.metadata?.fieldLimits?.maxFields, maxFieldNameLength = this.pleroma?.metadata?.fieldLimits?.nameLength, maxFieldValueLength = this.pleroma?.metadata?.fieldLimits?.valueLength, translationEnabled = this.configuration?.translation?.enabled @@ -205,6 +207,9 @@ class InstanceInfoRepository @Inject constructor( companion object { private const val TAG = "InstanceInfoRepo" + /** In-memory cache for instance data, per instance domain. */ + private var instanceInfoCache = ConcurrentHashMap() + const val DEFAULT_CHARACTER_LIMIT = 500 private const val DEFAULT_MAX_OPTION_COUNT = 4 private const val DEFAULT_MAX_OPTION_LENGTH = 50 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 71d19c17c..4f7ce8369 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 @@ -17,7 +17,6 @@ package com.keylesspalace.tusky.components.login import android.content.Context import android.content.Intent -import android.content.SharedPreferences import android.net.Uri import android.os.Bundle import android.text.method.LinkMovementMethod @@ -25,39 +24,41 @@ import android.util.Log import android.view.Menu import android.view.View import android.widget.TextView -import androidx.appcompat.app.AlertDialog import androidx.core.net.toUri +import androidx.core.view.WindowInsetsCompat.Type.ime +import androidx.core.view.WindowInsetsCompat.Type.systemBars +import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import at.connyduck.calladapter.networkresult.fold import com.bumptech.glide.Glide +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ActivityLoginBinding -import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.AccessToken import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.openLinkInCustomTab import com.keylesspalace.tusky.util.rickRoll +import com.keylesspalace.tusky.util.setOnWindowInsetsChangeListener import com.keylesspalace.tusky.util.shouldRickRoll -import com.keylesspalace.tusky.util.supportsOverridingActivityTransitions import com.keylesspalace.tusky.util.viewBinding +import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.launch import okhttp3.HttpUrl /** Main login page, the first thing that users see. Has prompt for instance and login button. */ -class LoginActivity : BaseActivity(), Injectable { +@AndroidEntryPoint +class LoginActivity : BaseActivity() { @Inject lateinit var mastodonApi: MastodonApi private val binding by viewBinding(ActivityLoginBinding::inflate) - private lateinit var preferences: SharedPreferences - private val oauthRedirectUri: String get() { val scheme = getString(R.string.oauth_scheme) @@ -67,9 +68,7 @@ class LoginActivity : BaseActivity(), Injectable { private val doWebViewAuth = registerForActivityResult(OauthLogin()) { result -> when (result) { - is LoginResult.Ok -> lifecycleScope.launch { - fetchOauthToken(result.code) - } + is LoginResult.Ok -> fetchOauthToken(result.code) is LoginResult.Err -> displayError(result.errorMessage) is LoginResult.Cancel -> setLoading(false) } @@ -80,19 +79,19 @@ class LoginActivity : BaseActivity(), Injectable { setContentView(binding.root) + binding.loginScrollView.setOnWindowInsetsChangeListener { windowInsets -> + val insets = windowInsets.getInsets(systemBars() or ime()) + binding.loginScrollView.updatePadding(bottom = insets.bottom) + } + if (savedInstanceState == null && BuildConfig.CUSTOM_INSTANCE.isNotBlank() && - !isAdditionalLogin() && !isAccountMigration() + !isAdditionalLogin() ) { binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE) binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length) } - if (isAccountMigration()) { - binding.domainEditText.setText(accountManager.activeAccount!!.domain) - binding.domainEditText.isEnabled = false - } - if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) { Glide.with(binding.loginLogo) .load(BuildConfig.CUSTOM_LOGO_URL) @@ -100,16 +99,11 @@ class LoginActivity : BaseActivity(), Injectable { .into(binding.loginLogo) } - preferences = getSharedPreferences( - getString(R.string.preferences_file_key), - Context.MODE_PRIVATE - ) - binding.loginButton.setOnClickListener { onLoginClick(true) } binding.registerButton.setOnClickListener { onRegisterClick() } binding.whatsAnInstanceTextView.setOnClickListener { - val dialog = AlertDialog.Builder(this) + val dialog = MaterialAlertDialogBuilder(this) .setMessage(R.string.dialog_whats_an_instance) .setPositiveButton(R.string.action_close, null) .show() @@ -118,7 +112,7 @@ class LoginActivity : BaseActivity(), Injectable { } setSupportActionBar(binding.toolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(isAdditionalLogin() || isAccountMigration()) + supportActionBar?.setDisplayHomeAsUpEnabled(isAdditionalLogin()) supportActionBar?.setDisplayShowTitleEnabled(false) } @@ -184,11 +178,7 @@ class LoginActivity : BaseActivity(), Injectable { getString(R.string.tusky_website) ).fold( { credentials -> - // Before we open browser page we save the data. - // Even if we don't open other apps user may go to password manager or somewhere else - // and we will need to pick up the process where we left off. - // Alternatively we could pass it all as part of the intent and receive it back - // but it is a bit of a workaround. + // Save credentials so we can access them after we opened another activity for auth. preferences.edit() .putString(DOMAIN, domain) .putString(CLIENT_ID, credentials.clientId) @@ -216,17 +206,15 @@ class LoginActivity : BaseActivity(), Injectable { ) { // To authorize this app and log in it's necessary to redirect to the domain given, // login there, and the server will redirect back to the app with its response. - val uri = HttpUrl.Builder() + val uri = Uri.Builder() .scheme("https") - .host(domain) - .addPathSegments(MastodonApi.ENDPOINT_AUTHORIZE) - .addQueryParameter("client_id", clientId) - .addQueryParameter("redirect_uri", oauthRedirectUri) - .addQueryParameter("response_type", "code") - .addQueryParameter("scope", OAUTH_SCOPES) + .authority(domain) + .path(MastodonApi.ENDPOINT_AUTHORIZE) + .appendQueryParameter("client_id", clientId) + .appendQueryParameter("redirect_uri", oauthRedirectUri) + .appendQueryParameter("response_type", "code") + .appendQueryParameter("scope", OAUTH_SCOPES) .build() - .toString() - .toUri() if (openInWebView) { doWebViewAuth.launch(LoginData(domain, uri, oauthRedirectUri.toUri())) @@ -247,15 +235,8 @@ class LoginActivity : BaseActivity(), Injectable { val code = uri.getQueryParameter("code") val error = uri.getQueryParameter("error") - /* restore variables from SharedPreferences */ - val domain = preferences.getNonNullString(DOMAIN, "") - val clientId = preferences.getNonNullString(CLIENT_ID, "") - val clientSecret = preferences.getNonNullString(CLIENT_SECRET, "") - - if (code != null && domain.isNotEmpty() && clientId.isNotEmpty() && clientSecret.isNotEmpty()) { - lifecycleScope.launch { - fetchOauthToken(code) - } + if (code != null) { + fetchOauthToken(code) } else { displayError(error) } @@ -275,37 +256,39 @@ class LoginActivity : BaseActivity(), Injectable { getString(R.string.error_authorization_unknown) } else { // Use error returned by the server or fall back to the generic message - Log.e(TAG, "%s %s".format(getString(R.string.error_authorization_denied), error)) + Log.e(TAG, getString(R.string.error_authorization_denied) + " " + error) error.ifBlank { getString(R.string.error_authorization_denied) } } } - private suspend fun fetchOauthToken(code: String) { + private fun fetchOauthToken(code: String) { + setLoading(true) + /* restore variables from SharedPreferences */ val domain = preferences.getNonNullString(DOMAIN, "") val clientId = preferences.getNonNullString(CLIENT_ID, "") val clientSecret = preferences.getNonNullString(CLIENT_SECRET, "") - setLoading(true) - - mastodonApi.fetchOAuthToken( - domain, - clientId, - clientSecret, - oauthRedirectUri, - code, - "authorization_code" - ).fold( - { accessToken -> - fetchAccountDetails(accessToken, domain, clientId, clientSecret) - }, - { e -> - setLoading(false) - binding.domainTextInputLayout.error = - getString(R.string.error_retrieving_oauth_token) - Log.e(TAG, getString(R.string.error_retrieving_oauth_token), e) - } - ) + lifecycleScope.launch { + mastodonApi.fetchOAuthToken( + domain, + clientId, + clientSecret, + oauthRedirectUri, + code, + "authorization_code" + ).fold( + { accessToken -> + fetchAccountDetails(accessToken, domain, clientId, clientSecret) + }, + { e -> + setLoading(false) + binding.domainTextInputLayout.error = + getString(R.string.error_retrieving_oauth_token) + Log.e(TAG, getString(R.string.error_retrieving_oauth_token), e) + } + ) + } } private suspend fun fetchAccountDetails( @@ -326,15 +309,10 @@ class LoginActivity : BaseActivity(), Injectable { oauthScopes = OAUTH_SCOPES, newAccount = newAccount ) - + finishAffinity() val intent = Intent(this, MainActivity::class.java) intent.putExtra(MainActivity.OPEN_WITH_EXPLODE_ANIMATION, true) startActivity(intent) - finishAffinity() - if (!supportsOverridingActivityTransitions()) { - @Suppress("DEPRECATION") - overridePendingTransition(R.anim.explode, R.anim.activity_open_exit) - } }, { e -> setLoading(false) binding.domainTextInputLayout.error = @@ -358,10 +336,6 @@ class LoginActivity : BaseActivity(), Injectable { return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_ADDITIONAL_LOGIN } - private fun isAccountMigration(): Boolean { - return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_MIGRATION - } - companion object { private const val TAG = "LoginActivity" // logging tag private const val OAUTH_SCOPES = "read write follow push" @@ -373,9 +347,6 @@ 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 - @JvmStatic fun getIntent(context: Context, mode: Int): Intent { val loginIntent = Intent(context, LoginActivity::class.java) 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 421f81032..29c63ddee 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 @@ -32,20 +32,22 @@ import android.webkit.WebView import android.webkit.WebViewClient import androidx.activity.result.contract.ActivityResultContract import androidx.activity.viewModels -import androidx.appcompat.app.AlertDialog -import androidx.core.content.IntentCompat import androidx.core.net.toUri +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsCompat.Type.ime +import androidx.core.view.WindowInsetsCompat.Type.systemBars +import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ActivityLoginWebviewBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory -import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.getParcelableExtraCompat import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible -import javax.inject.Inject +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @@ -62,9 +64,8 @@ class OauthLogin : ActivityResultContract() { return if (resultCode == Activity.RESULT_CANCELED) { LoginResult.Cancel } else { - intent?.let { - IntentCompat.getParcelableExtra(it, RESULT_EXTRA, LoginResult::class.java) - } ?: LoginResult.Err("failed parsing LoginWebViewActivity result") + intent?.getParcelableExtraCompat(RESULT_EXTRA) + ?: LoginResult.Err("failed parsing LoginWebViewActivity result") } } @@ -73,7 +74,7 @@ class OauthLogin : ActivityResultContract() { private const val DATA_EXTRA = "data" fun parseData(intent: Intent): LoginData { - return IntentCompat.getParcelableExtra(intent, DATA_EXTRA, LoginData::class.java)!! + return intent.getParcelableExtraCompat(DATA_EXTRA)!! } fun makeResultIntent(result: LoginResult): Intent { @@ -103,13 +104,11 @@ sealed interface LoginResult : Parcelable { } /** Activity to do Oauth process using WebView. */ -class LoginWebViewActivity : BaseActivity(), Injectable { +@AndroidEntryPoint +class LoginWebViewActivity : BaseActivity() { private val binding by viewBinding(ActivityLoginWebviewBinding::inflate) - @Inject - lateinit var viewModelFactory: ViewModelFactory - - private val viewModel: LoginWebViewViewModel by viewModels { viewModelFactory } + private val viewModel: LoginWebViewViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -128,10 +127,15 @@ class LoginWebViewActivity : BaseActivity(), Injectable { setTitle(R.string.title_login) + ViewCompat.setOnApplyWindowInsetsListener(binding.loginWebView) { _, insets -> + val bottomInsets = insets.getInsets(systemBars() or ime()).bottom + binding.root.updatePadding(bottom = bottomInsets) + WindowInsetsCompat.CONSUMED + } + val webView = binding.loginWebView webView.settings.allowContentAccess = false webView.settings.allowFileAccess = false - webView.settings.databaseEnabled = false webView.settings.displayZoomControls = false webView.settings.javaScriptCanOpenWindowsAutomatically = false // JavaScript needs to be enabled because otherwise 2FA does not work in some instances @@ -201,7 +205,7 @@ class LoginWebViewActivity : BaseActivity(), Injectable { viewModel.instanceRules.collect { instanceRules -> binding.loginRules.visible(instanceRules.isNotEmpty()) binding.loginRules.setOnClickListener { - AlertDialog.Builder(this@LoginWebViewActivity) + MaterialAlertDialogBuilder(this@LoginWebViewActivity) .setTitle(getString(R.string.instance_rule_title, data.domain)) .setMessage( instanceRules.joinToString(separator = "\n\n") { "• $it" } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt index 2e9198d16..7ce8cfdbb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt @@ -21,11 +21,13 @@ import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.isHttpNotFound +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +@HiltViewModel class LoginWebViewViewModel @Inject constructor( private val api: MastodonApi ) : ViewModel() { @@ -49,11 +51,11 @@ class LoginWebViewViewModel @Inject constructor( { instance -> _instanceRules.value = instance.rules.map { rule -> rule.text } }, - { throwable -> + { throwable2 -> Log.w( "LoginWebViewViewModel", "failed to load instance info", - throwable + throwable2 ) } ) 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..485ec0ef7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt @@ -0,0 +1,91 @@ +/* Copyright 2024 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.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 FollowViewHolder( + private val binding: ItemFollowBinding, + private val listener: AccountActionListener, + private val linkListener: LinkListener +) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder { + + override fun bind( + viewData: NotificationViewData.Concrete, + payloads: List<*>, + statusDisplayOptions: StatusDisplayOptions + ) { + if (payloads.isNotEmpty()) { + return + } + val context = itemView.context + val account = viewData.account + val messageTemplate = + context.getString(if (viewData.type == Notification.Type.SignUp) R.string.notification_sign_up_format else R.string.notification_follow_format) + val wrappedDisplayName = account.name.unicodeWrap() + + binding.notificationText.text = messageTemplate.format(wrappedDisplayName) + .emojify(account.emojis, binding.notificationText, statusDisplayOptions.animateEmojis) + + binding.notificationUsername.text = context.getString(R.string.post_username_format, viewData.account.username) + + val emojifiedDisplayName = wrappedDisplayName.emojify( + account.emojis, + binding.notificationDisplayName, + statusDisplayOptions.animateEmojis + ) + binding.notificationDisplayName.text = emojifiedDisplayName + + if (account.note.isEmpty()) { + binding.accountNote.hide() + } else { + binding.accountNote.show() + + val emojifiedNote = account.note.parseAsMastodonHtml() + .emojify(account.emojis, binding.accountNote, statusDisplayOptions.animateEmojis) + setClickableText(binding.accountNote, emojifiedNote, emptyList(), null, linkListener) + } + + val avatarRadius = context.resources + .getDimensionPixelSize(R.dimen.avatar_radius_42dp) + loadAvatar( + account.avatar, + binding.notificationAvatar, + avatarRadius, + statusDisplayOptions.animateAvatars + ) + + binding.avatarBadge.visible(statusDisplayOptions.showBotOverlay && account.bot) + + itemView.setOnClickListener { listener.onViewAccount(account.id) } + binding.accountNote.setOnClickListener { listener.onViewAccount(account.id) } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/ModerationWarningViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/ModerationWarningViewHolder.kt new file mode 100644 index 000000000..7cfdf39e6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/ModerationWarningViewHolder.kt @@ -0,0 +1,47 @@ +/* Copyright 2025 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 android.content.Intent +import androidx.core.net.toUri +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.databinding.ItemModerationWarningNotificationBinding +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.NotificationViewData + +class ModerationWarningViewHolder( + private val binding: ItemModerationWarningNotificationBinding, + private val instanceDomain: String +) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder { + + override fun bind( + viewData: NotificationViewData.Concrete, + payloads: List<*>, + statusDisplayOptions: StatusDisplayOptions + ) { + if (payloads.isNotEmpty()) { + return + } + val warning = viewData.moderationWarning!! + + binding.moderationWarningDescription.setText(warning.action.text) + + binding.root.setOnClickListener { + val intent = Intent(Intent.ACTION_VIEW, "https://$instanceDomain/disputes/strikes/${warning.id}".toUri()) + binding.root.context.startActivity(intent) + } + } +} 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 deleted file mode 100644 index a2059ba64..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java +++ /dev/null @@ -1,866 +0,0 @@ -/* Copyright 2018 Jeremiasz Nelz - * 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.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; - -import android.app.NotificationChannel; -import android.app.NotificationChannelGroup; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.os.Build; -import android.os.Bundle; -import android.provider.Settings; -import android.service.notification.StatusBarNotification; -import android.text.TextUtils; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.core.app.NotificationCompat; -import androidx.core.app.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; - -import com.bumptech.glide.Glide; -import com.bumptech.glide.load.resource.bitmap.RoundedCorners; -import com.bumptech.glide.request.FutureTarget; -import com.keylesspalace.tusky.MainActivity; -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.components.compose.ComposeActivity; -import com.keylesspalace.tusky.db.AccountEntity; -import com.keylesspalace.tusky.db.AccountManager; -import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Poll; -import com.keylesspalace.tusky.entity.PollOption; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver; -import com.keylesspalace.tusky.util.StringUtils; -import com.keylesspalace.tusky.viewdata.PollViewDataKt; -import com.keylesspalace.tusky.worker.NotificationWorker; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; - -public class NotificationHelper { - - /** ID of notification shown when fetching notifications */ - public static final int NOTIFICATION_ID_FETCH_NOTIFICATION = 0; - /** ID of notification shown when pruning the cache */ - public static final int NOTIFICATION_ID_PRUNE_CACHE = 1; - /** Dynamic notification IDs start here */ - private static int notificationId = NOTIFICATION_ID_PRUNE_CACHE + 1; - - private static final String TAG = "NotificationHelper"; - - public static final String REPLY_ACTION = "REPLY_ACTION"; - - public static final String KEY_REPLY = "KEY_REPLY"; - - public static final String KEY_SENDER_ACCOUNT_ID = "KEY_SENDER_ACCOUNT_ID"; - - public static final String KEY_SENDER_ACCOUNT_IDENTIFIER = "KEY_SENDER_ACCOUNT_IDENTIFIER"; - - public static final String KEY_SENDER_ACCOUNT_FULL_NAME = "KEY_SENDER_ACCOUNT_FULL_NAME"; - - public static final String KEY_SERVER_NOTIFICATION_ID = "KEY_SERVER_NOTIFICATION_ID"; - - public static final String KEY_CITED_STATUS_ID = "KEY_CITED_STATUS_ID"; - - public static final String KEY_VISIBILITY = "KEY_VISIBILITY"; - - public static final String KEY_SPOILER = "KEY_SPOILER"; - - public static final String KEY_MENTIONS = "KEY_MENTIONS"; - - /** - * notification channels used on Android O+ - **/ - public static final String CHANNEL_MENTION = "CHANNEL_MENTION"; - public static final String CHANNEL_FOLLOW = "CHANNEL_FOLLOW"; - public static final String CHANNEL_FOLLOW_REQUEST = "CHANNEL_FOLLOW_REQUEST"; - public static final String CHANNEL_BOOST = "CHANNEL_BOOST"; - public static final String CHANNEL_FAVOURITE = "CHANNEL_FAVOURITE"; - public static final String CHANNEL_POLL = "CHANNEL_POLL"; - public static final String CHANNEL_SUBSCRIPTIONS = "CHANNEL_SUBSCRIPTIONS"; - public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP"; - public static final String CHANNEL_UPDATES = "CHANNEL_UPDATES"; - public static final String CHANNEL_REPORT = "CHANNEL_REPORT"; - public static final String CHANNEL_BACKGROUND_TASKS = "CHANNEL_BACKGROUND_TASKS"; - - /** - * WorkManager Tag - */ - private static final String NOTIFICATION_PULL_TAG = "pullNotifications"; - - /** Tag for the summary notification */ - private static final String GROUP_SUMMARY_TAG = APPLICATION_ID + ".notification.group_summary"; - - /** The name of the account that caused the notification, for use in a summary */ - private static final String EXTRA_ACCOUNT_NAME = APPLICATION_ID + ".notification.extra.account_name"; - - /** The notification's type (string representation of a Notification.Type) */ - private static final String EXTRA_NOTIFICATION_TYPE = APPLICATION_ID + ".notification.extra.notification_type"; - - /** - * Takes a given Mastodon notification and 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 - */ - @NonNull - public static android.app.Notification make(final @NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull Notification body, @NonNull AccountEntity account, boolean isOnlyOneInGroup) { - body = body.rewriteToStatusTypeIfNeeded(account.getAccountId()); - String mastodonNotificationId = body.getId(); - int accountId = (int) account.getId(); - - // 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(); - } - } - - // Notification group member - // ========================= - - 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())); - - if (body.getType() == Notification.Type.MENTION || body.getType() == Notification.Type.POLL) { - builder.setStyle(new NotificationCompat.BigTextStyle() - .bigText(bodyForType(body, context, account.getAlwaysOpenSpoiler()))); - } - - //load the avatar synchronously - Bitmap accountAvatar; - try { - FutureTarget target = Glide.with(context) - .asBitmap() - .load(body.getAccount().getAvatar()) - .transform(new RoundedCorners(20)) - .submit(); - - accountAvatar = target.get(); - } catch (ExecutionException | InterruptedException e) { - Log.d(TAG, "error loading account avatar", e); - accountAvatar = BitmapFactory.decodeResource(context.getResources(), R.drawable.avatar_default); - } - - builder.setLargeIcon(accountAvatar); - - // Reply to mention action; RemoteInput is available from KitKat Watch, but buttons are available from Nougat - if (body.getType() == Notification.Type.MENTION) { - RemoteInput replyRemoteInput = new RemoteInput.Builder(KEY_REPLY) - .setLabel(context.getString(R.string.label_quick_reply)) - .build(); - - PendingIntent quickReplyPendingIntent = getStatusReplyIntent(context, body, account); - - NotificationCompat.Action quickReplyAction = - new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp, - context.getString(R.string.action_quick_reply), - quickReplyPendingIntent) - .addRemoteInput(replyRemoteInput) - .build(); - - builder.addAction(quickReplyAction); - - PendingIntent composeIntent = getStatusComposeIntent(context, body, account); - - NotificationCompat.Action composeAction = - new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp, - context.getString(R.string.action_compose_shortcut), - composeIntent) - .setShowsUserInterface(true) - .build(); - - builder.addAction(composeAction); - } - - builder.setSubText(account.getFullName()); - builder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE); - builder.setCategory(NotificationCompat.CATEGORY_SOCIAL); - builder.setOnlyAlertOnce(true); - - 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().name()); - builder.addExtras(extras); - - // Only alert for the first notification of a batch to avoid multiple alerts at once - if(!isOnlyOneInGroup) { - builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY); - } - - return builder.build(); - } - - /** - * 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(@NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull 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.getEntries()) { - channelGroups.put(getChannelId(account, ty), new ArrayList<>()); - } - - // 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; - - String channelId = sn.getNotification().getGroup(); - String summaryTag = GROUP_SUMMARY_TAG + "." + channelId; - if (summaryTag.equals(sn.getTag())) continue; - - // 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 typeName = members.get(0).getNotification().extras.getString(EXTRA_NOTIFICATION_TYPE, Notification.Type.UNKNOWN.name()); - Notification.Type notificationType = Notification.Type.valueOf(typeName); - - Intent summaryResultIntent = MainActivity.openNotificationIntent(context, accountId, 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 newAndroidNotification(Context context, Notification body, AccountEntity account) { - - Intent eventResultIntent = MainActivity.openNotificationIntent(context, account.getId(), body.getType()); - - TaskStackBuilder eventStackBuilder = TaskStackBuilder.create(context); - eventStackBuilder.addParentStack(MainActivity.class); - eventStackBuilder.addNextIntent(eventResultIntent); - - PendingIntent eventResultPendingIntent = eventStackBuilder.getPendingIntent((int) account.getId(), - pendingIntentFlags(false)); - - String channelId = getChannelId(account, body); - assert channelId != null; - - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId) - .setSmallIcon(R.drawable.ic_notify) - .setContentIntent(eventResultPendingIntent) - .setColor(context.getColor(R.color.notification_color)) - .setGroup(channelId) - .setAutoCancel(true) - .setShortcutId(Long.toString(account.getId())) - .setDefaults(0); // So it doesn't ring twice, notify only in Target callback - - setSoundVibrationLight(account, builder); - - return builder; - } - - private static PendingIntent getStatusReplyIntent(Context context, Notification body, AccountEntity account) { - Status status = body.getStatus(); - - String inReplyToId = status.getId(); - Status actionableStatus = status.getActionableStatus(); - Status.Visibility replyVisibility = actionableStatus.getVisibility(); - String contentWarning = actionableStatus.getSpoilerText(); - List mentions = actionableStatus.getMentions(); - List mentionedUsernames = new ArrayList<>(); - mentionedUsernames.add(actionableStatus.getAccount().getUsername()); - for (Status.Mention mention : mentions) { - mentionedUsernames.add(mention.getUsername()); - } - mentionedUsernames.removeAll(Collections.singleton(account.getUsername())); - mentionedUsernames = new ArrayList<>(new LinkedHashSet<>(mentionedUsernames)); - - Intent replyIntent = new Intent(context, SendStatusBroadcastReceiver.class) - .setAction(REPLY_ACTION) - .putExtra(KEY_SENDER_ACCOUNT_ID, account.getId()) - .putExtra(KEY_SENDER_ACCOUNT_IDENTIFIER, account.getIdentifier()) - .putExtra(KEY_SENDER_ACCOUNT_FULL_NAME, account.getFullName()) - .putExtra(KEY_SERVER_NOTIFICATION_ID, body.getId()) - .putExtra(KEY_CITED_STATUS_ID, inReplyToId) - .putExtra(KEY_VISIBILITY, replyVisibility) - .putExtra(KEY_SPOILER, contentWarning) - .putExtra(KEY_MENTIONS, mentionedUsernames.toArray(new String[0])); - - return PendingIntent.getBroadcast(context.getApplicationContext(), - notificationId, - replyIntent, - pendingIntentFlags(true)); - } - - private static PendingIntent getStatusComposeIntent(Context context, Notification body, AccountEntity account) { - Status status = body.getStatus(); - - String citedLocalAuthor = status.getAccount().getLocalUsername(); - String citedText = parseAsMastodonHtml(status.getContent()).toString(); - String inReplyToId = status.getId(); - Status actionableStatus = status.getActionableStatus(); - Status.Visibility replyVisibility = actionableStatus.getVisibility(); - String contentWarning = actionableStatus.getSpoilerText(); - List mentions = actionableStatus.getMentions(); - Set mentionedUsernames = new LinkedHashSet<>(); - mentionedUsernames.add(actionableStatus.getAccount().getUsername()); - for (Status.Mention mention : mentions) { - String mentionedUsername = mention.getUsername(); - if (!mentionedUsername.equals(account.getUsername())) { - mentionedUsernames.add(mention.getUsername()); - } - } - - ComposeActivity.ComposeOptions composeOptions = new ComposeActivity.ComposeOptions(); - composeOptions.setInReplyToId(inReplyToId); - composeOptions.setReplyVisibility(replyVisibility); - composeOptions.setContentWarning(contentWarning); - composeOptions.setReplyingStatusAuthor(citedLocalAuthor); - composeOptions.setReplyingStatusContent(citedText); - composeOptions.setMentionedUsernames(mentionedUsernames); - composeOptions.setModifiedInitialState(true); - composeOptions.setLanguage(actionableStatus.getLanguage()); - composeOptions.setKind(ComposeActivity.ComposeKind.NEW); - - Intent composeIntent = MainActivity.composeIntent(context, composeOptions, account.getId(), body.getId(), (int)account.getId()); - - // make sure a new instance of MainActivity is started and old ones get destroyed - composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - - return PendingIntent.getActivity(context.getApplicationContext(), - notificationId, - composeIntent, - pendingIntentFlags(false)); - } - - /** - * Creates a notification channel for notifications for background work that should not - * disturb the user. - * - * @param context context - */ - public static void createWorkerNotificationChannel(@NonNull Context context) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; - - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - - NotificationChannel channel = new NotificationChannel( - CHANNEL_BACKGROUND_TASKS, - context.getString(R.string.notification_listenable_worker_name), - NotificationManager.IMPORTANCE_NONE - ); - - channel.setDescription(context.getString(R.string.notification_listenable_worker_description)); - channel.enableLights(false); - channel.enableVibration(false); - channel.setShowBadge(false); - - notificationManager.createNotificationChannel(channel); - } - - /** - * Creates a notification for a background worker. - * - * @param context context - * @param titleResource String resource to use as the notification's title - * @return the notification - */ - @NonNull - public static android.app.Notification createWorkerNotification(@NonNull Context context, @StringRes int titleResource) { - String title = context.getString(titleResource); - return new NotificationCompat.Builder(context, CHANNEL_BACKGROUND_TASKS) - .setContentTitle(title) - .setTicker(title) - .setSmallIcon(R.drawable.ic_notify) - .setOngoing(true) - .build(); - } - - public static void createNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - - String[] channelIds = new String[]{ - CHANNEL_MENTION + account.getIdentifier(), - CHANNEL_FOLLOW + account.getIdentifier(), - CHANNEL_FOLLOW_REQUEST + account.getIdentifier(), - CHANNEL_BOOST + account.getIdentifier(), - CHANNEL_FAVOURITE + account.getIdentifier(), - CHANNEL_POLL + account.getIdentifier(), - CHANNEL_SUBSCRIPTIONS + account.getIdentifier(), - CHANNEL_SIGN_UP + account.getIdentifier(), - CHANNEL_UPDATES + account.getIdentifier(), - CHANNEL_REPORT + account.getIdentifier(), - }; - int[] channelNames = { - R.string.notification_mention_name, - R.string.notification_follow_name, - R.string.notification_follow_request_name, - R.string.notification_boost_name, - R.string.notification_favourite_name, - R.string.notification_poll_name, - R.string.notification_subscription_name, - R.string.notification_sign_up_name, - R.string.notification_update_name, - R.string.notification_report_name, - }; - int[] channelDescriptions = { - R.string.notification_mention_descriptions, - R.string.notification_follow_description, - R.string.notification_follow_request_description, - R.string.notification_boost_description, - R.string.notification_favourite_description, - R.string.notification_poll_description, - R.string.notification_subscription_description, - R.string.notification_sign_up_description, - R.string.notification_update_description, - R.string.notification_report_description, - }; - - List channels = new ArrayList<>(6); - - NotificationChannelGroup channelGroup = new NotificationChannelGroup(account.getIdentifier(), account.getFullName()); - - notificationManager.createNotificationChannelGroup(channelGroup); - - for (int i = 0; i < channelIds.length; i++) { - String id = channelIds[i]; - String name = context.getString(channelNames[i]); - String description = context.getString(channelDescriptions[i]); - int importance = NotificationManager.IMPORTANCE_DEFAULT; - NotificationChannel channel = new NotificationChannel(id, name, importance); - - channel.setDescription(description); - channel.enableLights(true); - channel.setLightColor(0xFF2B90D9); - channel.enableVibration(true); - channel.setShowBadge(true); - channel.setGroup(account.getIdentifier()); - channels.add(channel); - } - - notificationManager.createNotificationChannels(channels); - } - } - - public static void deleteNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - - notificationManager.deleteNotificationChannelGroup(account.getIdentifier()); - } - } - - public static boolean areNotificationsEnabled(@NonNull Context context, @NonNull AccountManager accountManager) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - - // on Android >= O, notifications are enabled, if at least one channel is enabled - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - - if (notificationManager.areNotificationsEnabled()) { - for (NotificationChannel channel : notificationManager.getNotificationChannels()) { - if (channel != null && channel.getImportance() > NotificationManager.IMPORTANCE_NONE) { - Log.d(TAG, "NotificationsEnabled"); - return true; - } - } - } - Log.d(TAG, "NotificationsDisabled"); - - return false; - - } else { - // on Android < O, notifications are enabled, if at least one account has notification enabled - return accountManager.areNotificationsEnabled(); - } - - } - - public static void enablePullNotifications(@NonNull Context context) { - 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, - PeriodicWorkRequest.MIN_PERIODIC_FLEX_MILLIS, TimeUnit.MILLISECONDS - ) - .addTag(NOTIFICATION_PULL_TAG) - .setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) - .setInitialDelay(5, TimeUnit.MINUTES) - .build(); - - workManager.enqueue(workRequest); - - Log.d(TAG, "enabled notification checks with "+ PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS + "ms interval"); - } - - public static void disablePullNotifications(@NonNull Context context) { - WorkManager.getInstance(context).cancelAllWorkByTag(NOTIFICATION_PULL_TAG); - Log.d(TAG, "disabled notification checks"); - } - - public static void clearNotificationsForAccount(@NonNull Context context, @NonNull AccountEntity account) { - int accountId = (int) account.getId(); - - 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(@NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification notification) { - return filterNotification(notificationManager, account, notification.getType()); - } - - public static boolean filterNotification(@NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification.Type type) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - String channelId = getChannelId(account, type); - if(channelId == null) { - // unknown notificationtype - return false; - } - NotificationChannel channel = notificationManager.getNotificationChannel(channelId); - return channel != null && channel.getImportance() > NotificationManager.IMPORTANCE_NONE; - } - - switch (type) { - case MENTION: - return account.getNotificationsMentioned(); - case STATUS: - return account.getNotificationsSubscriptions(); - case FOLLOW: - return account.getNotificationsFollowed(); - case FOLLOW_REQUEST: - return account.getNotificationsFollowRequested(); - case REBLOG: - return account.getNotificationsReblogged(); - case FAVOURITE: - return account.getNotificationsFavorited(); - case POLL: - return account.getNotificationsPolls(); - case SIGN_UP: - return account.getNotificationsSignUps(); - case UPDATE: - return account.getNotificationsUpdates(); - case REPORT: - return account.getNotificationsReports(); - default: - return false; - } - } - - @Nullable - private static String getChannelId(AccountEntity account, Notification notification) { - return getChannelId(account, notification.getType()); - } - - @Nullable - private static String getChannelId(AccountEntity account, Notification.Type type) { - switch (type) { - case MENTION: - return CHANNEL_MENTION + account.getIdentifier(); - case STATUS: - return CHANNEL_SUBSCRIPTIONS + account.getIdentifier(); - case FOLLOW: - return CHANNEL_FOLLOW + account.getIdentifier(); - case FOLLOW_REQUEST: - return CHANNEL_FOLLOW_REQUEST + account.getIdentifier(); - case REBLOG: - return CHANNEL_BOOST + account.getIdentifier(); - case FAVOURITE: - return CHANNEL_FAVOURITE + account.getIdentifier(); - case POLL: - return CHANNEL_POLL + account.getIdentifier(); - case SIGN_UP: - return CHANNEL_SIGN_UP + account.getIdentifier(); - case UPDATE: - return CHANNEL_UPDATES + account.getIdentifier(); - case REPORT: - return CHANNEL_REPORT + account.getIdentifier(); - default: - return null; - } - - } - - 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 - } - - if (account.getNotificationSound()) { - builder.setSound(Settings.System.DEFAULT_NOTIFICATION_URI); - } - - if (account.getNotificationVibration()) { - builder.setVibrate(new long[]{500, 500}); - } - - if (account.getNotificationLight()) { - builder.setLights(0xFF2B90D9, 300, 1000); - } - } - - 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, List notifications) { - if (notifications.size() > 3) { - int length = notifications.size(); - //notifications.get(0).getNotification().extras.getString(EXTRA_ACCOUNT_NAME); - return String.format(context.getString(R.string.notification_summary_large), - wrapItemAt(notifications.get(length - 1)), - wrapItemAt(notifications.get(length - 2)), - wrapItemAt(notifications.get(length - 3)), - length - 3); - } else if (notifications.size() == 3) { - return String.format(context.getString(R.string.notification_summary_medium), - wrapItemAt(notifications.get(2)), - wrapItemAt(notifications.get(1)), - wrapItemAt(notifications.get(0))); - } else if (notifications.size() == 2) { - return String.format(context.getString(R.string.notification_summary_small), - wrapItemAt(notifications.get(1)), - wrapItemAt(notifications.get(0))); - } - - return null; - } - - @Nullable - private static String titleForType(Context context, Notification notification, AccountEntity account) { - String accountName = StringUtils.unicodeWrap(notification.getAccount().getName()); - switch (notification.getType()) { - case MENTION: - return String.format(context.getString(R.string.notification_mention_format), - accountName); - case STATUS: - return String.format(context.getString(R.string.notification_subscription_format), - accountName); - case FOLLOW: - return String.format(context.getString(R.string.notification_follow_format), - accountName); - case FOLLOW_REQUEST: - return String.format(context.getString(R.string.notification_follow_request_format), - accountName); - case FAVOURITE: - return String.format(context.getString(R.string.notification_favourite_format), - accountName); - case REBLOG: - return String.format(context.getString(R.string.notification_reblog_format), - accountName); - case POLL: - if(notification.getStatus().getAccount().getId().equals(account.getAccountId())) { - return context.getString(R.string.poll_ended_created); - } else { - return context.getString(R.string.poll_ended_voted); - } - case SIGN_UP: - return String.format(context.getString(R.string.notification_sign_up_format), accountName); - case UPDATE: - return String.format(context.getString(R.string.notification_update_format), accountName); - case REPORT: - return context.getString(R.string.notification_report_format, account.getDomain()); - } - return null; - } - - private static String bodyForType(Notification notification, Context context, Boolean alwaysOpenSpoiler) { - switch (notification.getType()) { - case FOLLOW: - case FOLLOW_REQUEST: - case SIGN_UP: - return "@" + notification.getAccount().getUsername(); - case MENTION: - case FAVOURITE: - case REBLOG: - case STATUS: - if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText()) && !alwaysOpenSpoiler) { - return notification.getStatus().getSpoilerText(); - } else { - return parseAsMastodonHtml(notification.getStatus().getContent()).toString(); - } - case POLL: - if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText()) && !alwaysOpenSpoiler) { - return notification.getStatus().getSpoilerText(); - } else { - StringBuilder builder = new StringBuilder(parseAsMastodonHtml(notification.getStatus().getContent())); - builder.append('\n'); - Poll poll = notification.getStatus().getPoll(); - List options = poll.getOptions(); - for(int i = 0; i < options.size(); ++i) { - PollOption option = options.get(i); - builder.append(buildDescription(option.getTitle(), - PollViewDataKt.calculatePercent(option.getVotesCount(), poll.getVotersCount(), poll.getVotesCount()), - poll.getOwnVotes().contains(i), - context)); - builder.append('\n'); - } - return builder.toString(); - } - case REPORT: - return context.getString( - R.string.notification_header_report_format, - StringUtils.unicodeWrap(notification.getAccount().getName()), - StringUtils.unicodeWrap(notification.getReport().getTargetAccount().getName()) - ); - } - return null; - } - - public static int pendingIntentFlags(boolean mutable) { - if (mutable) { - return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0); - } else { - return PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE; - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationPolicySummaryAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationPolicySummaryAdapter.kt new file mode 100644 index 000000000..c51c08475 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationPolicySummaryAdapter.kt @@ -0,0 +1,72 @@ +/* Copyright 2024 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 android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemFilteredNotificationsInfoBinding +import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity +import com.keylesspalace.tusky.util.BindingHolder +import java.text.NumberFormat + +class NotificationPolicySummaryAdapter( + private val onOpenDetails: () -> Unit +) : RecyclerView.Adapter>() { + + private var state: NotificationPolicyEntity? = null + + fun updateState(newState: NotificationPolicyEntity?) { + val oldShowInfo = state.shouldShowInfo() + val newShowInfo = newState.shouldShowInfo() + state = newState + if (oldShowInfo && !newShowInfo) { + notifyItemRemoved(0) + } else if (!oldShowInfo && newShowInfo) { + notifyItemInserted(0) + } else if (oldShowInfo && newShowInfo) { + notifyItemChanged(0) + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { + val binding = ItemFilteredNotificationsInfoBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + binding.root.setOnClickListener { + onOpenDetails() + } + return BindingHolder(binding) + } + + override fun getItemCount() = if (state.shouldShowInfo()) 1 else 0 + + override fun onBindViewHolder(holder: BindingHolder, position: Int) { + state?.let { policyState -> + val binding = holder.binding + val context = holder.binding.root.context + binding.notificationPolicySummaryDescription.text = context.getString(R.string.notifications_from_people_you_may_know, policyState.pendingRequestsCount) + binding.notificationPolicySummaryBadge.text = NumberFormat.getInstance().format(policyState.pendingNotificationsCount) + } + } + + private fun NotificationPolicyEntity?.shouldShowInfo(): Boolean { + return this != null && this.pendingNotificationsCount > 0 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt new file mode 100644 index 000000000..851f815cd --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt @@ -0,0 +1,129 @@ +/* Copyright 2024 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 com.keylesspalace.tusky.components.timeline.Placeholder +import com.keylesspalace.tusky.components.timeline.toAccount +import com.keylesspalace.tusky.components.timeline.toStatus +import com.keylesspalace.tusky.db.entity.NotificationDataEntity +import com.keylesspalace.tusky.db.entity.NotificationEntity +import com.keylesspalace.tusky.db.entity.NotificationReportEntity +import com.keylesspalace.tusky.db.entity.TimelineAccountEntity +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Report +import com.keylesspalace.tusky.util.toViewData +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData + +fun Placeholder.toNotificationEntity( + tuskyAccountId: Long +) = NotificationEntity( + id = this.id, + tuskyAccountId = tuskyAccountId, + type = null, + accountId = null, + statusId = null, + reportId = null, + event = null, + moderationWarning = null, + loading = loading +) + +fun Notification.toEntity( + tuskyAccountId: Long +) = NotificationEntity( + tuskyAccountId = tuskyAccountId, + type = type, + id = id, + accountId = account.id, + statusId = status?.reblog?.id ?: status?.id, + reportId = report?.id, + event = event, + moderationWarning = moderationWarning, + loading = false +) + +fun Notification.toViewData( + isShowingContent: Boolean, + isExpanded: Boolean, + isCollapsed: Boolean, +): NotificationViewData.Concrete = NotificationViewData.Concrete( + id = id, + type = type, + account = account, + statusViewData = status?.toViewData( + isShowingContent = isShowingContent, + isExpanded = isExpanded, + isCollapsed = isCollapsed + ), + report = report, + moderationWarning = moderationWarning, + event = event +) + +fun Report.toEntity( + tuskyAccountId: Long +) = NotificationReportEntity( + tuskyAccountId = tuskyAccountId, + serverId = id, + category = category, + statusIds = statusIds, + createdAt = createdAt, + targetAccountId = targetAccount.id +) + +fun NotificationDataEntity.toViewData( + translation: TranslationViewData? = null +): NotificationViewData { + if (type == null || account == null) { + return NotificationViewData.Placeholder(id = id, isLoading = loading) + } + + return NotificationViewData.Concrete( + id = id, + type = type, + account = account.toAccount(), + statusViewData = if (status != null && statusAccount != null) { + StatusViewData.Concrete( + status = status.toStatus(statusAccount), + isExpanded = this.status.expanded, + isShowingContent = this.status.contentShowing, + isCollapsed = this.status.contentCollapsed, + translation = translation + ) + } else { + null + }, + report = if (report != null && reportTargetAccount != null) { + report.toReport(reportTargetAccount) + } else { + null + }, + event = event, + moderationWarning = moderationWarning + ) +} + +fun NotificationReportEntity.toReport( + account: TimelineAccountEntity +) = Report( + id = serverId, + category = category, + statusIds = statusIds, + createdAt = createdAt, + targetAccount = account.toAccount() +) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt new file mode 100644 index 000000000..a24eb35eb --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -0,0 +1,572 @@ +/* Copyright 2024 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 android.content.DialogInterface +import android.content.SharedPreferences +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 android.widget.ArrayAdapter +import android.widget.ListView +import android.widget.PopupWindow +import androidx.coordinatorlayout.widget.CoordinatorLayout +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.recyclerview.widget.ConcatAdapter +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.SimpleItemAnimator +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import at.connyduck.calladapter.networkresult.onFailure +import at.connyduck.sparkbutton.helpers.Utils +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.notifications.requests.NotificationRequestsActivity +import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder +import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData +import com.keylesspalace.tusky.components.systemnotifications.NotificationService +import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding +import com.keylesspalace.tusky.databinding.NotificationsFilterBinding +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.fragment.SFragment +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.interfaces.ReselectableFragment +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.CardViewMode +import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.StatusProvider +import com.keylesspalace.tusky.util.ensureBottomPadding +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.openLink +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation +import com.keylesspalace.tusky.util.updateRelativeTimePeriodically +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.viewdata.AttachmentViewData +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class NotificationsFragment : + SFragment(R.layout.fragment_timeline_notifications), + SwipeRefreshLayout.OnRefreshListener, + StatusActionListener, + NotificationActionListener, + AccountActionListener, + MenuProvider, + ReselectableFragment { + + @Inject + lateinit var preferences: SharedPreferences + + @Inject + lateinit var eventHub: EventHub + + @Inject + lateinit var notificationService: NotificationService + + private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind) + + private val viewModel: NotificationsViewModel by viewModels() + + private var notificationsAdapter: NotificationsPagingAdapter? = null + private var notificationsPolicyAdapter: NotificationPolicySummaryAdapter? = null + + private var showNotificationsFilterBar: Boolean = true + private var readingOrder: ReadingOrder = ReadingOrder.NEWEST_FIRST + + /** see [com.keylesspalace.tusky.components.timeline.TimelineFragment] for explanation of the load more mechanism */ + private var loadMorePosition: Int? = null + private var statusIdBelowLoadMore: String? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + + val activeAccount = accountManager.activeAccount ?: return + + val statusDisplayOptions = StatusDisplayOptions( + animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), + mediaPreviewEnabled = activeAccount.mediaPreviewEnabled, + useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), + showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), + useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), + cardViewMode = if (preferences.getBoolean(PrefKeys.SHOW_CARDS_IN_TIMELINES, false)) { + CardViewMode.INDENTED + } else { + CardViewMode.NONE + }, + confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), + confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), + showSensitiveMedia = activeAccount.alwaysShowSensitiveMedia, + openSpoiler = activeAccount.alwaysOpenSpoiler + ) + + binding.recyclerView.ensureBottomPadding(fab = true) + + // setup the notifications filter bar + showNotificationsFilterBar = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true) + updateFilterBarVisibility() + binding.buttonClear.setOnClickListener { confirmClearNotifications() } + binding.buttonFilter.setOnClickListener { showFilterMenu() } + + // Setup the SwipeRefreshLayout. + binding.swipeRefreshLayout.setOnRefreshListener(this) + + // Setup the RecyclerView. + binding.recyclerView.setHasFixedSize(true) + val adapter = NotificationsPagingAdapter( + accountId = activeAccount.accountId, + statusListener = this, + notificationActionListener = this, + accountActionListener = this, + statusDisplayOptions = statusDisplayOptions, + instanceName = activeAccount.domain + ) + this.notificationsAdapter = adapter + binding.recyclerView.layoutManager = LinearLayoutManager(context) + binding.recyclerView.setAccessibilityDelegateCompat( + ListStatusAccessibilityDelegate( + binding.recyclerView, + this, + StatusProvider { pos: Int -> + if (pos in 0 until adapter.itemCount) { + val notification = adapter.peek(pos) + // We support replies only for now + if (notification is NotificationViewData.Concrete) { + return@StatusProvider notification.statusViewData + } else { + return@StatusProvider null + } + } else { + null + } + } + ) + ) + + val notificationsPolicyAdapter = NotificationPolicySummaryAdapter { + (activity as BaseActivity).startActivityWithSlideInAnimation(NotificationRequestsActivity.newIntent(requireContext())) + } + this.notificationsPolicyAdapter = notificationsPolicyAdapter + + binding.recyclerView.adapter = ConcatAdapter(notificationsPolicyAdapter, notificationsAdapter) + + (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + + binding.recyclerView.addItemDecoration( + DividerItemDecoration(context, DividerItemDecoration.VERTICAL) + ) + + readingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null)) + + notificationsPolicyAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + binding.recyclerView.scrollToPosition(0) + } + }) + + adapter.addLoadStateListener { loadState -> + if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) { + binding.swipeRefreshLayout.isRefreshing = false + } + + binding.statusView.hide() + binding.progressBar.hide() + + if (adapter.itemCount == 0) { + when (loadState.refresh) { + is LoadState.NotLoading -> { + if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) { + binding.statusView.show() + binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty) + } + } + is LoadState.Error -> { + binding.statusView.show() + binding.statusView.setup((loadState.refresh as LoadState.Error).error) { onRefresh() } + } + is LoadState.Loading -> { + binding.progressBar.show() + } + } + } + } + + adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + val firstPos = (binding.recyclerView.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() + if (firstPos == 0 && positionStart == 0 && adapter.itemCount != itemCount) { + binding.recyclerView.post { + if (getView() != null) { + binding.recyclerView.scrollBy( + 0, + Utils.dpToPx(binding.recyclerView.context, -30) + ) + } + } + loadMorePosition = null + } + if (readingOrder == ReadingOrder.OLDEST_FIRST) { + updateReadingPositionForOldestFirst(adapter) + } + } + }) + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.notifications.collectLatest { pagingData -> + adapter.submitData(pagingData) + } + } + + viewLifecycleOwner.lifecycleScope.launch { + eventHub.events.collect { event -> + if (event is PreferenceChangedEvent) { + onPreferenceChanged(adapter, event.preferenceKey) + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + accountManager.activeAccount?.let { account -> + notificationService.clearNotificationsForAccount(account) + } + } + } + + updateRelativeTimePeriodically(preferences, adapter) + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.notificationPolicy.collect { + notificationsPolicyAdapter.updateState(it) + } + } + } + + override fun onDestroyView() { + // Clear the adapters to prevent leaking the View + notificationsAdapter = null + notificationsPolicyAdapter = null + super.onDestroyView() + } + + override fun onReselect() { + if (view != null) { + binding.recyclerView.layoutManager?.scrollToPosition(0) + binding.recyclerView.stopScroll() + } + } + + override fun onRefresh() { + notificationsAdapter?.refresh() + viewModel.loadNotificationPolicy() + } + + override fun onViewAccount(id: String) { + super.viewAccount(id) + } + + override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { + // not needed, muting via the more menu on statuses is handled in SFragment + } + + override fun onBlock(block: Boolean, id: String, position: Int) { + // not needed, blocking via the more menu on statuses is handled in SFragment + } + + override fun onRespondToFollowRequest(accept: Boolean, accountIdRequestingFollow: String, position: Int) { + val notification = notificationsAdapter?.peek(position) ?: return + viewModel.respondToFollowRequest(accept, accountIdRequestingFollow = accountIdRequestingFollow, notificationId = notification.id) + } + + override fun onViewReport(reportId: String) { + requireContext().openLink( + "https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId" + ) + } + + override fun onViewTag(tag: String) { + super.viewTag(tag) + } + + override fun onReply(position: Int) { + val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return + super.reply(status.status) + } + + override fun removeItem(position: Int) { + val notification = notificationsAdapter?.peek(position) ?: return + viewModel.remove(notification.id) + } + + override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) { + val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return + viewModel.reblog(reblog, status, visibility) + } + + override val onMoreTranslate: (translate: Boolean, position: Int) -> Unit + get() = { translate: Boolean, position: Int -> + if (translate) { + onTranslate(position) + } else { + onUntranslate(position) + } + } + + private fun onTranslate(position: Int) { + val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return + viewLifecycleOwner.lifecycleScope.launch { + viewModel.translate(status) + .onFailure { + Snackbar.make( + requireView(), + getString(R.string.ui_error_translate, it.message), + Snackbar.LENGTH_LONG + ).show() + } + } + } + + override fun onUntranslate(position: Int) { + val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return + viewModel.untranslate(status) + } + + override fun onFavourite(favourite: Boolean, position: Int) { + val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return + viewModel.favorite(favourite, status) + } + + override fun onBookmark(bookmark: Boolean, position: Int) { + val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return + viewModel.bookmark(bookmark, status) + } + + override fun onVoteInPoll(position: Int, choices: List) { + val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return + viewModel.voteInPoll(choices, status) + } + + override fun clearWarningAction(position: Int) { + val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return + viewModel.clearWarning(status) + } + + override fun onMore(view: View, position: Int) { + val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return + super.more( + status.status, + view, + position, + (status.translation as? TranslationViewData.Loaded)?.data + ) + } + + override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { + val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return + super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view) + } + + override fun onViewThread(position: Int) { + val status = notificationsAdapter?.peek(position)?.asStatusOrNull()?.status ?: return + super.viewThread(status.id, status.url) + } + + override fun onOpenReblog(position: Int) { + val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return + super.openReblog(status.status) + } + + override fun onExpandedChange(expanded: Boolean, position: Int) { + val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return + viewModel.changeExpanded(expanded, status) + } + + override fun onContentHiddenChange(isShowing: Boolean, position: Int) { + val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return + viewModel.changeContentShowing(isShowing, status) + } + + override fun onLoadMore(position: Int) { + val adapter = this.notificationsAdapter + val placeholder = adapter?.peek(position)?.asPlaceholderOrNull() ?: return + loadMorePosition = position + statusIdBelowLoadMore = + if (position + 1 < adapter.itemCount) adapter.peek(position + 1)?.id else null + viewModel.loadMore(placeholder.id) + } + + override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { + val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return + viewModel.changeContentCollapsed(isCollapsed, status) + } + + private fun confirmClearNotifications() { + MaterialAlertDialogBuilder(requireContext()) + .setMessage(R.string.notification_clear_text) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> clearNotifications() } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun clearNotifications() { + viewModel.clearNotifications() + } + + private fun showFilterMenu() { + val notificationTypeList = NotificationChannelData.entries.map { type -> + getString(type.title) + } + + val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_multiple_choice, notificationTypeList) + val window = PopupWindow(requireContext(), null, com.google.android.material.R.attr.listPopupWindowStyle) + val menuBinding = NotificationsFilterBinding.inflate(LayoutInflater.from(requireContext()), binding.root as ViewGroup, false) + + menuBinding.buttonApply.setOnClickListener { + val checkedItems = menuBinding.listView.getCheckedItemPositions() + val excludes = NotificationChannelData.entries.filterIndexed { index, _ -> + !checkedItems[index, false] + } + window.dismiss() + viewModel.updateNotificationFilters(excludes.toSet()) + } + + menuBinding.listView.setAdapter(adapter) + menuBinding.listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE) + + NotificationChannelData.entries.forEachIndexed { index, type -> + menuBinding.listView.setItemChecked(index, !viewModel.excludes.value.contains(type)) + } + + window.setContentView(menuBinding.root) + window.isFocusable = true + window.width = ViewGroup.LayoutParams.WRAP_CONTENT + window.height = ViewGroup.LayoutParams.WRAP_CONTENT + window.showAsDropDown(binding.buttonFilter) + } + + private fun onPreferenceChanged(adapter: NotificationsPagingAdapter, key: String) { + when (key) { + PrefKeys.MEDIA_PREVIEW_ENABLED -> { + val enabled = accountManager.activeAccount!!.mediaPreviewEnabled + val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled + if (enabled != oldMediaPreviewEnabled) { + adapter.mediaPreviewEnabled = enabled + } + } + + PrefKeys.SHOW_NOTIFICATIONS_FILTER -> { + if (view != null) { + showNotificationsFilterBar = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true) + updateFilterBarVisibility() + } + } + + PrefKeys.READING_ORDER -> { + readingOrder = ReadingOrder.from( + preferences.getString(PrefKeys.READING_ORDER, null) + ) + } + } + } + + private fun updateFilterBarVisibility() { + val params = binding.swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams + if (showNotificationsFilterBar) { + binding.appBarOptions.setExpanded(true, false) + binding.appBarOptions.show() + // Set content behaviour to hide filter on scroll + params.behavior = AppBarLayout.ScrollingViewBehavior() + } else { + binding.appBarOptions.setExpanded(false, false) + binding.appBarOptions.hide() + // Clear behaviour to hide app bar + params.behavior = null + } + } + + private fun updateReadingPositionForOldestFirst(adapter: NotificationsPagingAdapter) { + var position = loadMorePosition ?: return + val notificationIdBelowLoadMore = statusIdBelowLoadMore ?: return + + var notification: NotificationViewData? + while (adapter.peek(position).let { + notification = it + it != null + } + ) { + if (notification?.id == notificationIdBelowLoadMore) { + val lastVisiblePosition = + (binding.recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() + if (position > lastVisiblePosition) { + binding.recyclerView.scrollToPosition(position) + } + break + } + position++ + } + loadMorePosition = null + } + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.fragment_notifications, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem) = when (menuItem.itemId) { + R.id.action_refresh -> { + binding.swipeRefreshLayout.isRefreshing = true + onRefresh() + true + } + R.id.action_edit_notification_filter -> { + showFilterMenu() + true + } + R.id.action_clear_notifications -> { + confirmClearNotifications() + true + } + else -> false + } + + companion object { + fun newInstance() = NotificationsFragment() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt new file mode 100644 index 000000000..394791b04 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt @@ -0,0 +1,220 @@ +/* Copyright 2024 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 android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.FilteredStatusViewHolder +import com.keylesspalace.tusky.adapter.FollowRequestViewHolder +import com.keylesspalace.tusky.adapter.PlaceholderViewHolder +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.databinding.ItemFollowBinding +import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding +import com.keylesspalace.tusky.databinding.ItemModerationWarningNotificationBinding +import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding +import com.keylesspalace.tusky.databinding.ItemSeveredRelationshipNotificationBinding +import com.keylesspalace.tusky.databinding.ItemStatusFilteredBinding +import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding +import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding +import com.keylesspalace.tusky.databinding.ItemUnknownNotificationBinding +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.NotificationViewData + +interface NotificationActionListener { + fun onViewReport(reportId: String) +} + +interface NotificationsViewHolder { + fun bind( + viewData: NotificationViewData.Concrete, + payloads: List<*>, + statusDisplayOptions: StatusDisplayOptions + ) +} + +class NotificationsPagingAdapter( + private val accountId: String, + private var statusDisplayOptions: StatusDisplayOptions, + private val statusListener: StatusActionListener, + private val notificationActionListener: NotificationActionListener, + private val accountActionListener: AccountActionListener, + private val instanceName: String +) : PagingDataAdapter(NotificationsDifferCallback) { + + var mediaPreviewEnabled: Boolean + get() = statusDisplayOptions.mediaPreviewEnabled + set(mediaPreviewEnabled) { + statusDisplayOptions = statusDisplayOptions.copy( + mediaPreviewEnabled = mediaPreviewEnabled + ) + notifyItemRangeChanged(0, itemCount) + } + + private val absoluteTimeFormatter = AbsoluteTimeFormatter() + + init { + stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY + } + + override fun getItemViewType(position: Int): Int { + return when (val notification = getItem(position)) { + is NotificationViewData.Concrete -> { + when (notification.type) { + Notification.Type.Mention, + Notification.Type.Poll -> if (notification.statusViewData?.filterAction == Filter.Action.WARN) { + VIEW_TYPE_STATUS_FILTERED + } else { + VIEW_TYPE_STATUS + } + Notification.Type.Status, + Notification.Type.Update -> if (notification.statusViewData?.filterAction == Filter.Action.WARN) { + VIEW_TYPE_STATUS_FILTERED + } else { + VIEW_TYPE_STATUS_NOTIFICATION + } + Notification.Type.Favourite, + Notification.Type.Reblog -> VIEW_TYPE_STATUS_NOTIFICATION + Notification.Type.Follow, + Notification.Type.SignUp -> VIEW_TYPE_FOLLOW + Notification.Type.FollowRequest -> VIEW_TYPE_FOLLOW_REQUEST + Notification.Type.Report -> VIEW_TYPE_REPORT + Notification.Type.SeveredRelationship -> VIEW_TYPE_SEVERED_RELATIONSHIP + Notification.Type.ModerationWarning -> VIEW_TYPE_MODERATION_WARNING + else -> VIEW_TYPE_UNKNOWN + } + } + else -> VIEW_TYPE_PLACEHOLDER + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + VIEW_TYPE_STATUS -> StatusViewHolder( + inflater.inflate(R.layout.item_status, parent, false), + statusListener, + accountId + ) + VIEW_TYPE_STATUS_FILTERED -> FilteredStatusViewHolder( + ItemStatusFilteredBinding.inflate(inflater, parent, false), + statusListener + ) + VIEW_TYPE_STATUS_NOTIFICATION -> StatusNotificationViewHolder( + ItemStatusNotificationBinding.inflate(inflater, parent, false), + statusListener, + absoluteTimeFormatter + ) + VIEW_TYPE_FOLLOW -> FollowViewHolder( + ItemFollowBinding.inflate(inflater, parent, false), + accountActionListener, + statusListener + ) + VIEW_TYPE_FOLLOW_REQUEST -> FollowRequestViewHolder( + ItemFollowRequestBinding.inflate(inflater, parent, false), + accountActionListener, + statusListener, + true + ) + VIEW_TYPE_PLACEHOLDER -> PlaceholderViewHolder( + ItemStatusPlaceholderBinding.inflate(inflater, parent, false), + statusListener + ) + VIEW_TYPE_REPORT -> ReportNotificationViewHolder( + ItemReportNotificationBinding.inflate(inflater, parent, false), + notificationActionListener, + accountActionListener + ) + VIEW_TYPE_SEVERED_RELATIONSHIP -> SeveredRelationshipNotificationViewHolder( + ItemSeveredRelationshipNotificationBinding.inflate(inflater, parent, false), + instanceName + ) + VIEW_TYPE_MODERATION_WARNING -> ModerationWarningViewHolder( + ItemModerationWarningNotificationBinding.inflate(inflater, parent, false), + instanceName + ) + else -> UnknownNotificationViewHolder( + ItemUnknownNotificationBinding.inflate(inflater, parent, false) + ) + } + } + + override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) { + onBindViewHolder(viewHolder, position, emptyList()) + } + + override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int, payloads: List) { + getItem(position)?.let { notification -> + when (notification) { + is NotificationViewData.Concrete -> + (viewHolder as NotificationsViewHolder).bind(notification, payloads, statusDisplayOptions) + is NotificationViewData.Placeholder -> { + (viewHolder as PlaceholderViewHolder).setup(notification.isLoading) + } + } + } + } + + companion object { + private const val VIEW_TYPE_STATUS = 0 + private const val VIEW_TYPE_STATUS_FILTERED = 1 + private const val VIEW_TYPE_STATUS_NOTIFICATION = 2 + private const val VIEW_TYPE_FOLLOW = 3 + private const val VIEW_TYPE_FOLLOW_REQUEST = 4 + private const val VIEW_TYPE_PLACEHOLDER = 5 + private const val VIEW_TYPE_REPORT = 6 + private const val VIEW_TYPE_SEVERED_RELATIONSHIP = 7 + private const val VIEW_TYPE_MODERATION_WARNING = 8 + private const val VIEW_TYPE_UNKNOWN = 9 + + val NotificationsDifferCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: NotificationViewData, + newItem: NotificationViewData + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: NotificationViewData, + newItem: NotificationViewData + ): Boolean { + return false // Items are different always. It allows to refresh timestamp on every view holder update + } + + override fun getChangePayload( + oldItem: NotificationViewData, + newItem: NotificationViewData + ): Any? { + return if (oldItem == newItem) { + // If items are equal - update timestamp only + StatusBaseViewHolder.Key.KEY_CREATED + } else { + // If items are different - update the whole view holder + null + } + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt new file mode 100644 index 000000000..57c6f0411 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt @@ -0,0 +1,213 @@ +/* Copyright 2024 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 android.util.Log +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import androidx.room.withTransaction +import com.keylesspalace.tusky.components.systemnotifications.toTypes +import com.keylesspalace.tusky.components.timeline.Placeholder +import com.keylesspalace.tusky.components.timeline.toEntity +import com.keylesspalace.tusky.components.timeline.util.ifExpected +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.entity.AccountEntity +import com.keylesspalace.tusky.db.entity.NotificationDataEntity +import com.keylesspalace.tusky.db.entity.TimelineStatusEntity +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.isLessThan +import retrofit2.HttpException + +@OptIn(ExperimentalPagingApi::class) +class NotificationsRemoteMediator( + private val viewModel: NotificationsViewModel, + private val accountManager: AccountManager, + private val api: MastodonApi, + private val db: AppDatabase +) : RemoteMediator() { + + private var initialRefresh = false + + private val notificationsDao = db.notificationsDao() + private val accountDao = db.timelineAccountDao() + private val statusDao = db.timelineStatusDao() + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + val activeAccount = viewModel.activeAccountFlow.value + if (activeAccount == null) { + return MediatorResult.Success(endOfPaginationReached = true) + } + + val excludes = viewModel.excludes.value.toTypes() + + try { + var dbEmpty = false + + val topPlaceholderId = if (loadType == LoadType.REFRESH) { + notificationsDao.getTopPlaceholderId(activeAccount.id) + } else { + null // don't execute the query if it is not needed + } + + if (!initialRefresh && loadType == LoadType.REFRESH) { + val topId = notificationsDao.getTopId(activeAccount.id) + topId?.let { cachedTopId -> + val notificationResponse = api.notifications( + maxId = cachedTopId, + // so already existing placeholders don't get accidentally overwritten + sinceId = topPlaceholderId, + limit = state.config.pageSize, + excludes = excludes + ) + + val notifications = notificationResponse.body() + if (notificationResponse.isSuccessful && notifications != null) { + db.withTransaction { + replaceNotificationRange(notifications, state, activeAccount) + } + } + } + initialRefresh = true + dbEmpty = topId == null + } + + val notificationResponse = when (loadType) { + LoadType.REFRESH -> { + api.notifications(sinceId = topPlaceholderId, limit = state.config.pageSize, excludes = excludes) + } + LoadType.PREPEND -> { + return MediatorResult.Success(endOfPaginationReached = true) + } + LoadType.APPEND -> { + val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.id + api.notifications(maxId = maxId, limit = state.config.pageSize, excludes = excludes) + } + } + + val notifications = notificationResponse.body() + if (!notificationResponse.isSuccessful || notifications == null) { + return MediatorResult.Error(HttpException(notificationResponse)) + } + + db.withTransaction { + val overlappedNotifications = replaceNotificationRange(notifications, state, activeAccount) + + /* In case we loaded a whole page and there was no overlap with existing statuses, + we insert a placeholder because there might be even more unknown statuses */ + if (loadType == LoadType.REFRESH && overlappedNotifications == 0 && notifications.size == state.config.pageSize && !dbEmpty) { + /* This overrides the last of the newly loaded statuses with a placeholder + to guarantee the placeholder has an id that exists on the server as not all + servers handle client generated ids as expected */ + notificationsDao.insertNotification( + Placeholder(notifications.last().id, loading = false).toNotificationEntity(activeAccount.id) + ) + } + } + return MediatorResult.Success(endOfPaginationReached = notifications.isEmpty()) + } catch (e: Exception) { + return ifExpected(e) { + Log.w(TAG, "Failed to load notifications", e) + MediatorResult.Error(e) + } + } + } + + /** + * Deletes all notifications in a given range and inserts new notifications. + * This is necessary so notifications that have been deleted on the server are cleaned up. + * Should be run in a transaction as it executes multiple db updates + * @param notifications the new notifications + * @return the number of old notifications that have been cleared from the database + */ + private suspend fun replaceNotificationRange( + notifications: List, + state: PagingState, + activeAccount: AccountEntity + ): Int { + val overlappedNotifications = if (notifications.isNotEmpty()) { + notificationsDao.deleteRange(activeAccount.id, notifications.last().id, notifications.first().id) + } else { + 0 + } + + for (notification in notifications) { + accountDao.insert(notification.account.toEntity(activeAccount.id)) + notification.report?.let { report -> + accountDao.insert(report.targetAccount.toEntity(activeAccount.id)) + notificationsDao.insertReport(report.toEntity(activeAccount.id)) + } + + // check if we already have one of the newly loaded statuses cached locally + // in case we do, copy the local state (expanded, contentShowing, contentCollapsed) over so it doesn't get lost + var oldStatus: TimelineStatusEntity? = null + for (page in state.pages) { + oldStatus = page.data.find { s -> + s.id == notification.id + }?.status + if (oldStatus != null) break + } + + notification.status?.let { status -> + val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler + val contentShowing = oldStatus?.contentShowing ?: (activeAccount.alwaysShowSensitiveMedia || !status.sensitive) + val contentCollapsed = oldStatus?.contentCollapsed ?: true + + val statusToInsert = status.reblog ?: status + accountDao.insert(statusToInsert.account.toEntity(activeAccount.id)) + statusDao.insert( + statusToInsert.toEntity( + tuskyAccountId = activeAccount.id, + expanded = expanded, + contentShowing = contentShowing, + contentCollapsed = contentCollapsed + ) + ) + } + + notificationsDao.insertNotification( + notification.toEntity( + activeAccount.id + ) + ) + } + notifications.firstOrNull()?.let { notification -> + saveNewestNotificationId(notification) + } + return overlappedNotifications + } + + private suspend fun saveNewestNotificationId(notification: Notification) { + viewModel.activeAccountFlow.value?.let { activeAccount -> + val lastNotificationId: String = activeAccount.lastNotificationId + val newestNotificationId = notification.id + if (lastNotificationId.isLessThan(newestNotificationId)) { + Log.d(TAG, "saving newest noti id: $lastNotificationId for account ${activeAccount.id}") + accountManager.updateAccount(activeAccount) { copy(lastNotificationId = newestNotificationId) } + } + } + } + + companion object { + private const val TAG = "NotificationsRM" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt new file mode 100644 index 000000000..0a61857cf --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -0,0 +1,441 @@ +/* Copyright 2024 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 android.content.SharedPreferences +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 androidx.paging.filter +import androidx.paging.map +import androidx.room.withTransaction +import at.connyduck.calladapter.networkresult.NetworkResult +import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.map +import at.connyduck.calladapter.networkresult.onFailure +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.FilterUpdatedEvent +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder +import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData +import com.keylesspalace.tusky.components.systemnotifications.toTypes +import com.keylesspalace.tusky.components.timeline.Placeholder +import com.keylesspalace.tusky.components.timeline.toEntity +import com.keylesspalace.tusky.components.timeline.util.ifExpected +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.usecase.NotificationPolicyUsecase +import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import retrofit2.HttpException + +@HiltViewModel +class NotificationsViewModel @Inject constructor( + private val timelineCases: TimelineCases, + private val api: MastodonApi, + eventHub: EventHub, + private val accountManager: AccountManager, + private val preferences: SharedPreferences, + private val filterModel: FilterModel, + private val db: AppDatabase, + private val notificationPolicyUsecase: NotificationPolicyUsecase +) : ViewModel() { + + val activeAccountFlow = accountManager.activeAccount(viewModelScope) + private val accountId: Long = activeAccountFlow.value!!.id + + private val refreshTrigger = MutableStateFlow(0L) + + val excludes: StateFlow> = activeAccountFlow + .map { account -> account?.notificationsFilter.orEmpty() } + .stateIn(viewModelScope, SharingStarted.Eagerly, activeAccountFlow.value?.notificationsFilter.orEmpty()) + + /** Map from notification id to translation. */ + private val translations = MutableStateFlow(mapOf()) + + private var remoteMediator = NotificationsRemoteMediator(this, accountManager, api, db) + + private var readingOrder: ReadingOrder = + ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null)) + + @OptIn(ExperimentalPagingApi::class, ExperimentalCoroutinesApi::class) + val notifications = refreshTrigger.flatMapLatest { + Pager( + config = PagingConfig( + pageSize = LOAD_AT_ONCE + ), + remoteMediator = remoteMediator, + pagingSourceFactory = { + db.notificationsDao().getNotifications(accountId) + } + ).flow + .cachedIn(viewModelScope) + .combine(translations) { pagingData, translations -> + pagingData.map { notification -> + val translation = translations[notification.status?.serverId] + notification.toViewData(translation = translation) + }.filter { notificationViewData -> + shouldFilterStatus(notificationViewData) != Filter.Action.HIDE + } + } + } + .flowOn(Dispatchers.Default) + + val notificationPolicy: Flow = notificationPolicyUsecase.info + + init { + viewModelScope.launch { + eventHub.events.collect { event -> + if (event is PreferenceChangedEvent) { + onPreferenceChanged(event.preferenceKey) + } + if (event is FilterUpdatedEvent && event.filterContext.contains(Filter.Kind.NOTIFICATIONS.kind)) { + filterModel.init(Filter.Kind.NOTIFICATIONS) + refreshTrigger.value += 1 + } + } + } + viewModelScope.launch { + val needsRefresh = filterModel.init(Filter.Kind.NOTIFICATIONS) + if (needsRefresh) { + refreshTrigger.value++ + } + } + loadNotificationPolicy() + } + + fun loadNotificationPolicy() { + viewModelScope.launch { + notificationPolicyUsecase.getNotificationPolicy() + } + } + + fun updateNotificationFilters(newFilters: Set) { + val account = activeAccountFlow.value + if (newFilters != excludes.value && account != null) { + viewModelScope.launch { + accountManager.updateAccount(account) { + copy(notificationsFilter = newFilters) + } + db.notificationsDao().cleanupNotifications(accountId, 0) + refreshTrigger.value++ + } + } + } + + private fun shouldFilterStatus(notificationViewData: NotificationViewData): Filter.Action { + return when ((notificationViewData as? NotificationViewData.Concrete)?.type) { + Notification.Type.Mention, Notification.Type.Poll, Notification.Type.Status, Notification.Type.Update -> { + val account = activeAccountFlow.value + notificationViewData.statusViewData?.let { statusViewData -> + if (statusViewData.status.account.id == account?.accountId) { + return Filter.Action.NONE + } + statusViewData.filterAction = filterModel.shouldFilterStatus(statusViewData.actionable) + return statusViewData.filterAction + } + Filter.Action.NONE + } + + else -> Filter.Action.NONE + } + } + + fun respondToFollowRequest(accept: Boolean, accountIdRequestingFollow: String, notificationId: String) { + viewModelScope.launch { + if (accept) { + api.authorizeFollowRequest(accountIdRequestingFollow) + } else { + api.rejectFollowRequest(accountIdRequestingFollow) + }.fold( + onSuccess = { + // since the follow request has been responded, the notification can be deleted. The Ui will update automatically. + db.notificationsDao().delete(accountId, notificationId) + if (accept) { + // refresh the notifications so the new follow notification will be loaded + refreshTrigger.value++ + } + }, + onFailure = { t -> + Log.e(TAG, "Failed to to respond to follow request from account id $accountIdRequestingFollow.", t) + } + ) + } + } + + fun reblog(reblog: Boolean, status: StatusViewData.Concrete, visibility: Status.Visibility = Status.Visibility.PUBLIC): Job = viewModelScope.launch { + timelineCases.reblog(status.actionableId, reblog, visibility).onFailure { t -> + ifExpected(t) { + Log.w(TAG, "Failed to reblog status " + status.actionableId, t) + } + } + } + + fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { + timelineCases.favourite(status.actionableId, favorite).onFailure { t -> + ifExpected(t) { + Log.d(TAG, "Failed to favourite status " + status.actionableId, t) + } + } + } + + fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { + timelineCases.bookmark(status.actionableId, bookmark).onFailure { t -> + ifExpected(t) { + Log.d(TAG, "Failed to bookmark status " + status.actionableId, t) + } + } + } + + fun voteInPoll(choices: List, status: StatusViewData.Concrete) = viewModelScope.launch { + val poll = status.status.actionableStatus.poll ?: run { + Log.d(TAG, "No poll on status ${status.id}") + return@launch + } + timelineCases.voteInPoll(status.actionableId, poll.id, choices).onFailure { t -> + ifExpected(t) { + Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t) + } + } + } + + fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { + viewModelScope.launch { + db.timelineStatusDao() + .setExpanded(accountId, status.id, expanded) + } + } + + fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { + viewModelScope.launch { + db.timelineStatusDao() + .setContentShowing(accountId, status.id, isShowing) + } + } + + fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { + viewModelScope.launch { + db.timelineStatusDao() + .setContentCollapsed(accountId, status.id, isCollapsed) + } + } + + fun remove(notificationId: String) { + viewModelScope.launch { + db.notificationsDao().delete(accountId, notificationId) + } + } + + fun clearWarning(status: StatusViewData.Concrete) { + viewModelScope.launch { + db.timelineStatusDao().clearWarning(accountId, status.actionableId) + } + } + + fun clearNotifications() { + viewModelScope.launch { + api.clearNotifications().fold( + { + db.notificationsDao().cleanupNotifications(accountId, 0) + }, + { t -> + Log.w(TAG, "failed to clear notifications", t) + } + ) + } + } + + suspend fun translate(status: StatusViewData.Concrete): NetworkResult { + translations.value += (status.id to TranslationViewData.Loading) + return timelineCases.translate(status.actionableId) + .map { translation -> + translations.value += (status.id to TranslationViewData.Loaded(translation)) + } + .onFailure { + translations.value -= status.id + } + } + + fun untranslate(status: StatusViewData.Concrete) { + translations.value -= status.id + } + + fun loadMore(placeholderId: String) { + viewModelScope.launch { + try { + val notificationsDao = db.notificationsDao() + + notificationsDao.insertNotification( + Placeholder(placeholderId, loading = true).toNotificationEntity( + accountId + ) + ) + + val (idAbovePlaceholder, idBelowPlaceholder) = db.withTransaction { + notificationsDao.getIdAbove(accountId, placeholderId) to + notificationsDao.getIdBelow(accountId, placeholderId) + } + val response = when (readingOrder) { + // Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately + // after minId and no larger than maxId + ReadingOrder.OLDEST_FIRST -> api.notifications( + maxId = idAbovePlaceholder, + minId = idBelowPlaceholder, + limit = TimelineViewModel.LOAD_AT_ONCE, + excludes = excludes.value.toTypes() + ) + // Using sinceId, loads up to LOAD_AT_ONCE statuses immediately before + // maxId, and no smaller than minId. + ReadingOrder.NEWEST_FIRST -> api.notifications( + maxId = idAbovePlaceholder, + sinceId = idBelowPlaceholder, + limit = TimelineViewModel.LOAD_AT_ONCE, + excludes = excludes.value.toTypes() + ) + } + + val notifications = response.body() + if (!response.isSuccessful || notifications == null) { + loadMoreFailed(placeholderId, HttpException(response)) + return@launch + } + + val account = activeAccountFlow.value + if (account == null) { + return@launch + } + + val statusDao = db.timelineStatusDao() + val accountDao = db.timelineAccountDao() + + db.withTransaction { + notificationsDao.delete(accountId, placeholderId) + + val overlappedNotifications = if (notifications.isNotEmpty()) { + notificationsDao.deleteRange( + accountId, + notifications.last().id, + notifications.first().id + ) + } else { + 0 + } + + for (notification in notifications) { + accountDao.insert(notification.account.toEntity(accountId)) + notification.report?.let { report -> + accountDao.insert(report.targetAccount.toEntity(accountId)) + notificationsDao.insertReport(report.toEntity(accountId)) + } + notification.status?.let { status -> + val statusToInsert = status.reblog ?: status + accountDao.insert(statusToInsert.account.toEntity(accountId)) + + statusDao.insert( + statusToInsert.toEntity( + tuskyAccountId = accountId, + expanded = account.alwaysOpenSpoiler, + contentShowing = account.alwaysShowSensitiveMedia || !status.sensitive, + contentCollapsed = true + ) + ) + } + notificationsDao.insertNotification( + notification.toEntity( + accountId + ) + ) + } + + /* In case we loaded a whole page and there was no overlap with existing notifications, + we insert a placeholder because there might be even more unknown notifications */ + if (overlappedNotifications == 0 && notifications.size == TimelineViewModel.LOAD_AT_ONCE) { + /* This overrides the first/last of the newly loaded notifications with a placeholder + to guarantee the placeholder has an id that exists on the server as not all + servers handle client generated ids as expected */ + val idToConvert = when (readingOrder) { + ReadingOrder.OLDEST_FIRST -> notifications.first().id + ReadingOrder.NEWEST_FIRST -> notifications.last().id + } + notificationsDao.insertNotification( + Placeholder( + idToConvert, + loading = false + ).toNotificationEntity(accountId) + ) + } + } + } catch (e: Exception) { + ifExpected(e) { + loadMoreFailed(placeholderId, e) + } + } + } + } + + private suspend fun loadMoreFailed(placeholderId: String, e: Exception) { + Log.w(TAG, "failed loading notifications", e) + val activeAccount = accountManager.activeAccount!! + db.notificationsDao() + .insertNotification( + Placeholder(placeholderId, loading = false).toNotificationEntity(activeAccount.id) + ) + } + + private fun onPreferenceChanged(key: String) { + when (key) { + PrefKeys.READING_ORDER -> { + readingOrder = ReadingOrder.from( + preferences.getString(PrefKeys.READING_ORDER, null) + ) + } + } + } + + companion object { + private const val LOAD_AT_ONCE = 30 + private const val TAG = "NotificationsViewModel" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt deleted file mode 100644 index c89823e6d..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt +++ /dev/null @@ -1,262 +0,0 @@ -/* Copyright 2022 Tusky contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -@file:JvmName("PushNotificationHelper") - -package com.keylesspalace.tusky.components.notifications - -import android.app.NotificationManager -import android.content.Context -import android.os.Build -import android.util.Log -import android.view.View -import androidx.appcompat.app.AlertDialog -import androidx.preference.PreferenceManager -import at.connyduck.calladapter.networkresult.onFailure -import at.connyduck.calladapter.networkresult.onSuccess -import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.login.LoginActivity -import com.keylesspalace.tusky.db.AccountEntity -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.CryptoUtil -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.unifiedpush.android.connector.UnifiedPush - -private const val TAG = "PushNotificationHelper" - -private const val KEY_MIGRATION_NOTICE_DISMISSED = "migration_notice_dismissed" - -private fun anyAccountNeedsMigration(accountManager: AccountManager): Boolean = - accountManager.accounts.any(::accountNeedsMigration) - -private fun accountNeedsMigration(account: AccountEntity): Boolean = - !account.oauthScopes.contains("push") - -fun currentAccountNeedsMigration(accountManager: AccountManager): Boolean = - accountManager.activeAccount?.let(::accountNeedsMigration) ?: false - -fun showMigrationNoticeIfNecessary( - context: Context, - parent: View, - anchorView: View?, - accountManager: AccountManager -) { - // No point showing anything if we cannot enable it - if (!isUnifiedPushAvailable(context)) return - if (!anyAccountNeedsMigration(accountManager)) return - - val pm = PreferenceManager.getDefaultSharedPreferences(context) - if (pm.getBoolean(KEY_MIGRATION_NOTICE_DISMISSED, false)) return - - Snackbar.make(parent, R.string.tips_push_notification_migration, Snackbar.LENGTH_INDEFINITE) - .setAnchorView(anchorView) - .setAction( - R.string.action_details - ) { showMigrationExplanationDialog(context, accountManager) } - .show() -} - -private fun showMigrationExplanationDialog(context: Context, accountManager: AccountManager) { - AlertDialog.Builder(context).apply { - if (currentAccountNeedsMigration(accountManager)) { - setMessage(R.string.dialog_push_notification_migration) - setPositiveButton(R.string.title_migration_relogin) { _, _ -> - context.startActivity( - LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION) - ) - } - } else { - setMessage(R.string.dialog_push_notification_migration_other_accounts) - } - setNegativeButton(R.string.action_dismiss) { dialog, _ -> - val pm = PreferenceManager.getDefaultSharedPreferences(context) - pm.edit().putBoolean(KEY_MIGRATION_NOTICE_DISMISSED, true).apply() - dialog.dismiss() - } - show() - } -} - -private suspend fun enableUnifiedPushNotificationsForAccount( - context: Context, - api: MastodonApi, - accountManager: AccountManager, - account: AccountEntity -) { - if (isUnifiedPushNotificationEnabledForAccount(account)) { - // Already registered, update the subscription to match notification settings - updateUnifiedPushSubscription(context, api, accountManager, account) - } else { - UnifiedPush.registerAppWithDialog( - context, - account.id.toString(), - features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE) - ) - } -} - -fun disableUnifiedPushNotificationsForAccount(context: Context, account: AccountEntity) { - if (!isUnifiedPushNotificationEnabledForAccount(account)) { - // Not registered - return - } - - UnifiedPush.unregisterApp(context, account.id.toString()) -} - -fun isUnifiedPushNotificationEnabledForAccount(account: AccountEntity): Boolean = - account.unifiedPushUrl.isNotEmpty() - -private fun isUnifiedPushAvailable(context: Context): Boolean = - UnifiedPush.getDistributors(context).isNotEmpty() - -fun canEnablePushNotifications(context: Context, accountManager: AccountManager): Boolean = - isUnifiedPushAvailable(context) && !anyAccountNeedsMigration(accountManager) - -suspend fun enablePushNotificationsWithFallback( - context: Context, - api: MastodonApi, - accountManager: AccountManager -) { - if (!canEnablePushNotifications(context, accountManager)) { - // No UP distributors - NotificationHelper.enablePullNotifications(context) - return - } - - val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - accountManager.accounts.forEach { - val notificationGroupEnabled = Build.VERSION.SDK_INT < 28 || - nm.getNotificationChannelGroup(it.identifier)?.isBlocked == false - val shouldEnable = it.notificationsEnabled && notificationGroupEnabled - - if (shouldEnable) { - enableUnifiedPushNotificationsForAccount(context, api, accountManager, it) - } else { - disableUnifiedPushNotificationsForAccount(context, it) - } - } -} - -private fun disablePushNotifications(context: Context, accountManager: AccountManager) { - accountManager.accounts.forEach { - disableUnifiedPushNotificationsForAccount(context, it) - } -} - -fun disableAllNotifications(context: Context, accountManager: AccountManager) { - disablePushNotifications(context, accountManager) - NotificationHelper.disablePullNotifications(context) -} - -private fun buildSubscriptionData(context: Context, account: AccountEntity): Map = - buildMap { - val notificationManager = context.getSystemService( - Context.NOTIFICATION_SERVICE - ) as NotificationManager - Notification.Type.visibleTypes.forEach { - put( - "data[alerts][${it.presentation}]", - NotificationHelper.filterNotification(notificationManager, account, it) - ) - } - } - -// Called by UnifiedPush callback -suspend fun registerUnifiedPushEndpoint( - context: Context, - api: MastodonApi, - accountManager: AccountManager, - account: AccountEntity, - endpoint: String -) = withContext(Dispatchers.IO) { - // Generate a prime256v1 key pair for WebPush - // Decryption is unimplemented for now, since Mastodon uses an old WebPush - // standard which does not send needed information for decryption in the payload - // This makes it not directly compatible with UnifiedPush - // As of now, we use it purely as a way to trigger a pull - val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1) - val auth = CryptoUtil.secureRandomBytesEncoded(16) - - api.subscribePushNotifications( - "Bearer ${account.accessToken}", - account.domain, - endpoint, - keyPair.pubkey, - auth, - buildSubscriptionData(context, account) - ).onFailure { throwable -> - Log.w(TAG, "Error setting push endpoint for account ${account.id}", throwable) - disableUnifiedPushNotificationsForAccount(context, account) - }.onSuccess { - Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}") - - account.pushPubKey = keyPair.pubkey - account.pushPrivKey = keyPair.privKey - account.pushAuth = auth - account.pushServerKey = it.serverKey - account.unifiedPushUrl = endpoint - accountManager.saveAccount(account) - } -} - -// Synchronize the enabled / disabled state of notifications with server-side subscription -suspend fun updateUnifiedPushSubscription( - context: Context, - api: MastodonApi, - accountManager: AccountManager, - account: AccountEntity -) { - withContext(Dispatchers.IO) { - api.updatePushNotificationSubscription( - "Bearer ${account.accessToken}", - account.domain, - buildSubscriptionData(context, account) - ).onSuccess { - Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}") - - account.pushServerKey = it.serverKey - accountManager.saveAccount(account) - } - } -} - -suspend fun unregisterUnifiedPushEndpoint( - api: MastodonApi, - accountManager: AccountManager, - account: AccountEntity -) { - withContext(Dispatchers.IO) { - api.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain) - .onFailure { throwable -> - Log.w(TAG, "Error unregistering push endpoint for account " + account.id, throwable) - } - .onSuccess { - Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id) - // Clear the URL in database - account.unifiedPushUrl = "" - account.pushServerKey = "" - account.pushAuth = "" - account.pushPrivKey = "" - account.pushPubKey = "" - accountManager.saveAccount(account) - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/ReportNotificationViewHolder.kt similarity index 58% rename from app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt rename to app/src/main/java/com/keylesspalace/tusky/components/notifications/ReportNotificationViewHolder.kt index b894c372d..4081c8845 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/ReportNotificationViewHolder.kt @@ -13,86 +13,85 @@ * 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.notifications import android.content.Context -import androidx.core.content.ContextCompat +import android.text.TextUtils 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.databinding.ItemReportNotificationBinding -import com.keylesspalace.tusky.entity.Report -import com.keylesspalace.tusky.entity.TimelineAccount -import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.getRelativeTimeSpanString import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.unicodeWrap +import com.keylesspalace.tusky.util.updateEmojiTargets +import com.keylesspalace.tusky.viewdata.NotificationViewData class ReportNotificationViewHolder( - private val binding: ItemReportNotificationBinding -) : RecyclerView.ViewHolder(binding.root) { + private val binding: ItemReportNotificationBinding, + private val listener: NotificationActionListener, + private val accountActionListener: AccountActionListener +) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder { - fun setupWithReport( - reporter: TimelineAccount, - report: Report, - animateAvatar: Boolean, - animateEmojis: Boolean + override fun bind( + viewData: NotificationViewData.Concrete, + payloads: List<*>, + statusDisplayOptions: StatusDisplayOptions ) { - val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, binding.notificationTopText, animateEmojis) - val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, binding.notificationTopText, animateEmojis) + if (payloads.isNotEmpty()) { + return + } + val report = viewData.report!! + val reporter = viewData.account - val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp) + binding.notificationTopText.updateEmojiTargets { + val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, statusDisplayOptions.animateEmojis) + val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, statusDisplayOptions.animateEmojis) - binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null) - binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName) + // Context.getString() returns a String and doesn't support Spannable. + // Convert the placeholders to the format used by TextUtils.expandTemplate which does. + val topText = + view.context.getString(R.string.notification_header_report_format, "^1", "^2") + view.text = TextUtils.expandTemplate(topText, reporterName, reporteeName) + } binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, System.currentTimeMillis()), report.statusIds?.size ?: 0) binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category) - // Fancy avatar inset - val padding = Utils.dpToPx(binding.notificationReporteeAvatar.context, 12) - binding.notificationReporteeAvatar.setPaddingRelative(0, 0, padding, padding) - loadAvatar( report.targetAccount.avatar, binding.notificationReporteeAvatar, itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp), - animateAvatar + statusDisplayOptions.animateAvatars, ) loadAvatar( reporter.avatar, binding.notificationReporterAvatar, itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp), - animateAvatar + statusDisplayOptions.animateAvatars, ) - } - fun setupActionListener( - listener: NotificationActionListener, - reporteeId: String, - reporterId: String, - reportId: String - ) { binding.notificationReporteeAvatar.setOnClickListener { val position = bindingAdapterPosition if (position != RecyclerView.NO_POSITION) { - listener.onViewAccount(reporteeId) + accountActionListener.onViewAccount(report.targetAccount.id) } } binding.notificationReporterAvatar.setOnClickListener { val position = bindingAdapterPosition if (position != RecyclerView.NO_POSITION) { - listener.onViewAccount(reporterId) + accountActionListener.onViewAccount(reporter.id) } } - itemView.setOnClickListener { listener.onViewReport(reportId) } + itemView.setOnClickListener { listener.onViewReport(report.id) } } private fun getTranslatedCategory(context: Context, rawCategory: String): String { return when (rawCategory) { "violation" -> context.getString(R.string.report_category_violation) "spam" -> context.getString(R.string.report_category_spam) + "legal" -> context.getString(R.string.report_category_legal) "other" -> context.getString(R.string.report_category_other) else -> rawCategory } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/SeveredRelationshipNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/SeveredRelationshipNotificationViewHolder.kt new file mode 100644 index 000000000..541585ee3 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/SeveredRelationshipNotificationViewHolder.kt @@ -0,0 +1,46 @@ +/* Copyright 2025 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.components.systemnotifications.NotificationService +import com.keylesspalace.tusky.databinding.ItemSeveredRelationshipNotificationBinding +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.NotificationViewData + +class SeveredRelationshipNotificationViewHolder( + private val binding: ItemSeveredRelationshipNotificationBinding, + private val instanceName: String +) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder { + + override fun bind( + viewData: NotificationViewData.Concrete, + payloads: List<*>, + statusDisplayOptions: StatusDisplayOptions + ) { + if (payloads.isNotEmpty()) { + return + } + val event = viewData.event!! + val context = binding.root.context + + binding.severedRelationshipText.text = NotificationService.severedRelationShipText( + context, + event, + instanceName + ) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt new file mode 100644 index 000000000..862d824f5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt @@ -0,0 +1,390 @@ +/* + * 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 android.content.Context +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.text.InputFilter +import android.text.Spanned +import android.text.format.DateUtils +import android.text.style.StyleSpan +import android.view.View +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.text.toSpannable +import androidx.recyclerview.widget.RecyclerView +import at.connyduck.sparkbutton.helpers.Utils +import com.bumptech.glide.Glide +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter +import com.keylesspalace.tusky.util.SmartLengthInputFilter +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.getRelativeTimeSpanString +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.loadAvatar +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 +import com.keylesspalace.tusky.viewdata.StatusViewData +import java.util.Date + +internal class StatusNotificationViewHolder( + private val binding: ItemStatusNotificationBinding, + private val statusActionListener: StatusActionListener, + private val absoluteTimeFormatter: AbsoluteTimeFormatter +) : NotificationsViewHolder, RecyclerView.ViewHolder(binding.root) { + private val avatarRadius48dp = itemView.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_48dp + ) + private val avatarRadius36dp = itemView.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_36dp + ) + private val avatarRadius24dp = itemView.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_24dp + ) + + override fun bind( + viewData: NotificationViewData.Concrete, + payloads: List<*>, + statusDisplayOptions: StatusDisplayOptions + ) { + val statusViewData = viewData.statusViewData + if (payloads.isEmpty()) { + /* in some very rare cases servers sends null status even though they should not */ + if (statusViewData == null) { + showNotificationContent(false) + } else { + showNotificationContent(true) + val account = statusViewData.actionable.account + val createdAt = statusViewData.actionable.createdAt + setDisplayName(account.name, account.emojis, statusDisplayOptions.animateEmojis) + setUsername(account.username) + setCreatedAt(createdAt, statusDisplayOptions.useAbsoluteTime) + if (viewData.type == Notification.Type.Status || + viewData.type == Notification.Type.Update + ) { + setAvatar( + account.avatar, + account.bot, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.showBotOverlay + ) + } else { + setAvatars( + account.avatar, + viewData.account.avatar, + statusDisplayOptions.animateAvatars + ) + } + + val viewThreadListener = View.OnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + statusActionListener.onViewThread(position) + } + } + + binding.notificationContainer.setOnClickListener(viewThreadListener) + binding.notificationContent.setOnClickListener(viewThreadListener) + binding.notificationTopText.setOnClickListener { + statusActionListener.onViewAccount(viewData.account.id) + } + } + setMessage(viewData, statusActionListener, statusDisplayOptions.animateEmojis) + } else { + for (item in payloads) { + if (StatusBaseViewHolder.Key.KEY_CREATED == item && statusViewData != null) { + setCreatedAt( + statusViewData.status.actionableStatus.createdAt, + statusDisplayOptions.useAbsoluteTime + ) + } + } + } + } + + private fun showNotificationContent(show: Boolean) { + binding.statusDisplayName.visible(show) + binding.statusUsername.visible(show) + binding.statusMetaInfo.visible(show) + binding.notificationContentWarningDescription.visible(show) + binding.notificationContentWarningButton.visible(show) + binding.notificationContent.visible(show) + binding.notificationStatusAvatar.visible(show) + binding.notificationNotificationAvatar.visible(show) + binding.notificationAttachmentInfo.visible(show) + } + + private fun setDisplayName(name: String, emojis: List, animateEmojis: Boolean) { + val emojifiedName = name.emojify(emojis, binding.statusDisplayName, animateEmojis) + binding.statusDisplayName.text = emojifiedName + } + + private fun setUsername(name: String) { + val context = binding.statusUsername.context + val format = context.getString(R.string.post_username_format) + val usernameText = String.format(format, name) + binding.statusUsername.text = usernameText + } + + private fun setCreatedAt(createdAt: Date, useAbsoluteTime: Boolean) { + if (useAbsoluteTime) { + binding.statusMetaInfo.text = absoluteTimeFormatter.format(createdAt, true) + } else { + val readout: String // visible timestamp + val readoutAloud: CharSequence // for screenreaders so they don't mispronounce timestamps like "17m" as 17 meters + + val then = createdAt.time + val now = System.currentTimeMillis() + readout = getRelativeTimeSpanString(binding.statusMetaInfo.context, then, now) + readoutAloud = DateUtils.getRelativeTimeSpanString( + then, + now, + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ) + + binding.statusMetaInfo.text = readout + binding.statusMetaInfo.contentDescription = readoutAloud + } + } + + private fun getIconWithColor( + context: Context, + @DrawableRes drawable: Int, + @ColorRes color: Int + ): Drawable? { + val icon = AppCompatResources.getDrawable(context, drawable) + icon?.setTint(context.getColor(color)) + return icon + } + + private fun setAvatar(statusAvatarUrl: String?, isBot: Boolean, animateAvatars: Boolean, showBotOverlay: Boolean) { + binding.notificationStatusAvatar.setPaddingRelative(0, 0, 0, 0) + loadAvatar( + statusAvatarUrl, + binding.notificationStatusAvatar, + avatarRadius48dp, + animateAvatars + ) + if (showBotOverlay && isBot) { + binding.notificationNotificationAvatar.visibility = View.VISIBLE + Glide.with(binding.notificationNotificationAvatar) + .load(R.drawable.bot_badge) + .into(binding.notificationNotificationAvatar) + } else { + binding.notificationNotificationAvatar.visibility = View.GONE + } + } + + private fun setAvatars(statusAvatarUrl: String?, notificationAvatarUrl: String?, animateAvatars: Boolean) { + val padding = Utils.dpToPx(binding.notificationStatusAvatar.context, 12) + binding.notificationStatusAvatar.setPaddingRelative(0, 0, padding, padding) + loadAvatar( + statusAvatarUrl, + binding.notificationStatusAvatar, + avatarRadius36dp, + animateAvatars + ) + binding.notificationNotificationAvatar.visibility = View.VISIBLE + loadAvatar( + notificationAvatarUrl, + binding.notificationNotificationAvatar, + avatarRadius24dp, + animateAvatars + ) + } + + fun setMessage( + notificationViewData: NotificationViewData.Concrete, + listener: LinkListener, + animateEmojis: Boolean + ) { + val statusViewData = notificationViewData.statusViewData + val displayName = notificationViewData.account.name.unicodeWrap() + val type = notificationViewData.type + val context = binding.notificationTopText.context + val format: String + val icon: Drawable? + when (type) { + Notification.Type.Favourite -> { + icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange_light) + format = context.getString(R.string.notification_favourite_format) + } + Notification.Type.Reblog -> { + icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_orange) + format = context.getString(R.string.notification_reblog_format) + } + Notification.Type.Status -> { + icon = getIconWithColor(context, R.drawable.ic_notifications_active_24dp, R.color.chinwag_green) + format = context.getString(R.string.notification_subscription_format) + } + Notification.Type.Update -> { + icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.chinwag_green) + format = context.getString(R.string.notification_update_format) + } + else -> { + icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange_light) + format = context.getString(R.string.notification_favourite_format) + } + } + binding.notificationTopText.setCompoundDrawablesRelativeWithIntrinsicBounds( + icon, + null, + null, + null + ) + val wholeMessage = String.format(format, displayName).toSpannable() + val displayNameIndex = format.indexOf("%1\$s") + wholeMessage.setSpan( + StyleSpan(Typeface.BOLD), + displayNameIndex, + displayNameIndex + displayName.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + val emojifiedText = wholeMessage.emojify( + notificationViewData.account.emojis, + binding.notificationTopText, + animateEmojis + ) + binding.notificationTopText.text = emojifiedText + if (statusViewData != null) { + val hasSpoiler = statusViewData.status.spoilerText.isNotEmpty() + binding.notificationContentWarningDescription.visibility = + if (hasSpoiler) View.VISIBLE else View.GONE + binding.notificationContentWarningButton.visibility = + if (hasSpoiler) View.VISIBLE else View.GONE + if (statusViewData.isExpanded) { + binding.notificationContentWarningButton.setText( + R.string.post_content_warning_show_less + ) + } else { + binding.notificationContentWarningButton.setText( + R.string.post_content_warning_show_more + ) + } + binding.notificationContentWarningButton.setOnClickListener { + if (bindingAdapterPosition != RecyclerView.NO_POSITION) { + statusActionListener.onExpandedChange( + !statusViewData.isExpanded, + bindingAdapterPosition + ) + } + binding.notificationContent.visibility = + if (statusViewData.isExpanded) View.GONE else View.VISIBLE + } + setupContentAndSpoiler(listener, statusViewData, animateEmojis) + } + } + + private fun setupContentAndSpoiler( + listener: LinkListener, + statusViewData: StatusViewData.Concrete, + animateEmojis: Boolean + ) { + val shouldShowContentIfSpoiler = statusViewData.isExpanded + val hasSpoiler = statusViewData.status.spoilerText.isNotEmpty() + if (!shouldShowContentIfSpoiler && hasSpoiler) { + binding.notificationContent.visibility = View.GONE + } else { + binding.notificationContent.visibility = View.VISIBLE + } + val content = statusViewData.content + val emojis = statusViewData.actionable.emojis + if (statusViewData.isCollapsible && (statusViewData.isExpanded || !hasSpoiler)) { + binding.buttonToggleNotificationContent.setOnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + statusActionListener.onContentCollapsedChange( + !statusViewData.isCollapsed, + position + ) + } + } + binding.buttonToggleNotificationContent.visibility = View.VISIBLE + if (statusViewData.isCollapsed) { + binding.buttonToggleNotificationContent.setText( + R.string.post_content_warning_show_more + ) + binding.notificationContent.filters = COLLAPSE_INPUT_FILTER + binding.notificationAttachmentInfo.hide() + } else { + binding.buttonToggleNotificationContent.setText( + R.string.post_content_warning_show_less + ) + binding.notificationContent.filters = NO_INPUT_FILTER + setupAttachmentInfo(statusViewData.status) + } + } else { + binding.buttonToggleNotificationContent.visibility = View.GONE + binding.notificationContent.filters = NO_INPUT_FILTER + setupAttachmentInfo(statusViewData.status) + } + val emojifiedText = content.emojify( + emojis = emojis, + view = binding.notificationContent, + animate = animateEmojis + ) + setClickableText( + binding.notificationContent, + emojifiedText, + statusViewData.actionable.mentions, + statusViewData.actionable.tags, + listener, + ) + val emojifiedContentWarning: CharSequence = statusViewData.status.spoilerText.emojify( + statusViewData.actionable.emojis, + binding.notificationContentWarningDescription, + animateEmojis + ) + binding.notificationContentWarningDescription.text = emojifiedContentWarning + } + + private fun setupAttachmentInfo(status: Status) { + if (status.attachments.isNotEmpty()) { + binding.notificationAttachmentInfo.show() + binding.notificationAttachmentInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_attach_file_24dp, 0, 0, 0) + val attachmentCount = status.attachments.size + val attachmentText = binding.root.context.resources.getQuantityString(R.plurals.media_attachments, attachmentCount, attachmentCount) + binding.notificationAttachmentInfo.text = attachmentText + } else if (status.poll != null) { + binding.notificationAttachmentInfo.show() + binding.notificationAttachmentInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0) + binding.notificationAttachmentInfo.setText(R.string.poll) + } else { + binding.notificationAttachmentInfo.hide() + } + } + + companion object { + private val COLLAPSE_INPUT_FILTER: Array = arrayOf(SmartLengthInputFilter) + private val NO_INPUT_FILTER: Array = arrayOf() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt new file mode 100644 index 000000000..ef477c524 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt @@ -0,0 +1,92 @@ +/* + * 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 android.view.View +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusViewHolder +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.viewdata.NotificationViewData + +internal class StatusViewHolder( + itemView: View, + private val statusActionListener: StatusActionListener, + private val accountId: String +) : NotificationsViewHolder, StatusViewHolder(itemView) { + + override fun bind( + viewData: NotificationViewData.Concrete, + payloads: List<*>, + statusDisplayOptions: StatusDisplayOptions + ) { + val statusViewData = viewData.statusViewData + if (statusViewData == null) { + /* in some very rare cases servers sends null status even though they should not */ + showStatusContent(false) + } else { + if (payloads.isEmpty()) { + showStatusContent(true) + } + setupWithStatus( + statusViewData, + statusActionListener, + statusDisplayOptions, + payloads, + false + ) + if (payloads.isNotEmpty()) { + return + } + val res = itemView.resources + if (viewData.type == Notification.Type.Poll) { + statusInfo.setText(if (accountId == viewData.account.id) R.string.poll_ended_created else R.string.poll_ended_voted) + statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0) + statusInfo.setCompoundDrawablePadding(res.getDimensionPixelSize(R.dimen.status_info_drawable_padding_large)) + statusInfo.setPadding(res.getDimensionPixelSize(R.dimen.status_info_padding_large), 0, 0, 0) + statusInfo.show() + } else if (viewData.type == Notification.Type.Mention) { + statusInfo.setCompoundDrawablePadding(res.getDimensionPixelSize(R.dimen.status_info_drawable_padding_small)) + statusInfo.setPaddingRelative(res.getDimensionPixelSize(R.dimen.status_info_padding_small), 0, 0, 0) + statusInfo.show() + if (viewData.statusViewData.status.inReplyToAccountId == accountId) { + statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_reply_18dp, 0, 0, 0) + + if (viewData.statusViewData.status.visibility == Status.Visibility.DIRECT) { + statusInfo.setText(R.string.notification_info_private_reply) + } else { + statusInfo.setText(R.string.notification_info_reply) + } + } else { + statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_at_18dp, 0, 0, 0) + + if (viewData.statusViewData.status.visibility == Status.Visibility.DIRECT) { + statusInfo.setText(R.string.notification_info_private_mention) + } else { + statusInfo.setText(R.string.notification_info_mention) + } + } + } else { + hideStatusInfo() + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/UnknownNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/UnknownNotificationViewHolder.kt new file mode 100644 index 000000000..bb4841d35 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/UnknownNotificationViewHolder.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemUnknownNotificationBinding +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.NotificationViewData + +internal class UnknownNotificationViewHolder( + private val binding: ItemUnknownNotificationBinding, +) : NotificationsViewHolder, RecyclerView.ViewHolder(binding.root) { + + override fun bind( + viewData: NotificationViewData.Concrete, + payloads: List<*>, + statusDisplayOptions: StatusDisplayOptions + ) { + binding.unknownNotificationType.text = viewData.type.name + + binding.root.setOnClickListener { + MaterialAlertDialogBuilder(binding.root.context) + .setMessage(R.string.unknown_notification_type_explanation) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsActivity.kt new file mode 100644 index 000000000..c8e6fcae3 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsActivity.kt @@ -0,0 +1,204 @@ +/* Copyright 2024 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.requests + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.viewModels +import androidx.core.view.MenuProvider +import androidx.lifecycle.lifecycleScope +import androidx.paging.LoadState +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.SimpleItemAnimator +import com.google.android.material.color.MaterialColors +import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.notifications.requests.details.NotificationRequestDetailsActivity +import com.keylesspalace.tusky.components.preference.notificationpolicies.NotificationPoliciesActivity +import com.keylesspalace.tusky.databinding.ActivityNotificationRequestsBinding +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.NotificationRequest +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.getErrorString +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.util.visible +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.hilt.android.AndroidEntryPoint +import kotlin.String +import kotlin.getValue +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class NotificationRequestsActivity : BaseActivity(), MenuProvider { + + private val viewModel: NotificationRequestsViewModel by viewModels() + + private val binding by viewBinding(ActivityNotificationRequestsBinding::inflate) + + private val notificationRequestDetails = registerForActivityResult(NotificationRequestDetailsResultContract()) { id -> + if (id != null) { + viewModel.removeNotificationRequest(id) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(binding.root) + + addMenuProvider(this) + + setSupportActionBar(binding.includedToolbar.toolbar) + supportActionBar?.run { + setTitle(R.string.filtered_notifications_title) + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + setupAdapter().let { adapter -> + setupRecyclerView(adapter) + + lifecycleScope.launch { + viewModel.pager.collectLatest { pagingData -> + adapter.submitData(pagingData) + } + } + } + + lifecycleScope.launch { + viewModel.error.collect { error -> + Snackbar.make( + binding.root, + error.getErrorString(this@NotificationRequestsActivity), + LENGTH_LONG + ).show() + } + } + } + + private fun setupRecyclerView(adapter: NotificationRequestsAdapter) { + binding.notificationRequestsView.adapter = adapter + binding.notificationRequestsView.setHasFixedSize(true) + binding.notificationRequestsView.layoutManager = LinearLayoutManager(this) + binding.notificationRequestsView.addItemDecoration( + DividerItemDecoration(this, DividerItemDecoration.VERTICAL) + ) + (binding.notificationRequestsView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + } + + private fun setupAdapter(): NotificationRequestsAdapter { + return NotificationRequestsAdapter( + onAcceptRequest = viewModel::acceptNotificationRequest, + onDismissRequest = viewModel::dismissNotificationRequest, + onOpenDetails = ::onOpenRequestDetails, + animateAvatar = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + ).apply { + addLoadStateListener { loadState -> + binding.notificationRequestsProgressBar.visible( + loadState.refresh == LoadState.Loading && itemCount == 0 + ) + + if (loadState.refresh is LoadState.Error) { + binding.notificationRequestsView.hide() + binding.notificationRequestsMessageView.show() + val errorState = loadState.refresh as LoadState.Error + binding.notificationRequestsMessageView.setup(errorState.error) { retry() } + Log.w(TAG, "error loading notification requests", errorState.error) + } else { + binding.notificationRequestsView.show() + binding.notificationRequestsMessageView.hide() + } + } + } + } + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.activity_notification_requests, menu) + menu.findItem(R.id.open_settings)?.apply { + icon = IconicsDrawable(this@NotificationRequestsActivity, GoogleMaterial.Icon.gmd_settings).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.open_settings -> { + val intent = NotificationPoliciesActivity.newIntent(this) + startActivityWithSlideInAnimation(intent) + true + } + else -> false + } + } + + private fun onOpenRequestDetails(reqeuest: NotificationRequest) { + notificationRequestDetails.launch( + NotificationRequestDetailsResultContractInput( + notificationRequestId = reqeuest.id, + accountId = reqeuest.account.id, + accountName = reqeuest.account.name, + accountEmojis = reqeuest.account.emojis + ) + ) + } + + class NotificationRequestDetailsResultContractInput( + val notificationRequestId: String, + val accountId: String, + val accountName: String, + val accountEmojis: List + ) + + class NotificationRequestDetailsResultContract : ActivityResultContract() { + override fun createIntent(context: Context, input: NotificationRequestDetailsResultContractInput): Intent { + return NotificationRequestDetailsActivity.newIntent( + notificationRequestId = input.notificationRequestId, + accountId = input.accountId, + accountName = input.accountName, + accountEmojis = input.accountEmojis, + context = context + ) + } + + override fun parseResult(resultCode: Int, intent: Intent?): String? { + return intent?.getStringExtra(NotificationRequestDetailsActivity.EXTRA_NOTIFICATION_REQUEST_ID) + } + } + + companion object { + private const val TAG = "NotificationRequestsActivity" + fun newIntent(context: Context) = Intent(context, NotificationRequestsActivity::class.java) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsAdapter.kt new file mode 100644 index 000000000..1f88e8990 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsAdapter.kt @@ -0,0 +1,95 @@ +/* Copyright 2024 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.requests + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.annotation.OptIn +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import com.google.android.material.badge.ExperimentalBadgeUtils +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemNotificationRequestBinding +import com.keylesspalace.tusky.entity.NotificationRequest +import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.loadAvatar +import java.text.NumberFormat + +class NotificationRequestsAdapter( + private val onAcceptRequest: (notificationRequestId: String) -> Unit, + private val onDismissRequest: (notificationRequestId: String) -> Unit, + private val onOpenDetails: (notificationRequest: NotificationRequest) -> Unit, + private val animateAvatar: Boolean, + private val animateEmojis: Boolean, +) : PagingDataAdapter>(NOTIFICATION_REQUEST_COMPARATOR) { + + private val numberFormat: NumberFormat = NumberFormat.getNumberInstance() + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { + val binding = ItemNotificationRequestBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return BindingHolder(binding) + } + + @OptIn(ExperimentalBadgeUtils::class) + override fun onBindViewHolder(holder: BindingHolder, position: Int) { + getItem(position)?.let { notificationRequest -> + val binding = holder.binding + val context = binding.root.context + val account = notificationRequest.account + + val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) + loadAvatar(account.avatar, binding.notificationRequestAvatar, avatarRadius, animateAvatar) + + binding.notificationRequestBadge.text = numberFormat.format(notificationRequest.notificationsCount) + + val emojifiedName = account.name.emojify( + account.emojis, + binding.notificationRequestDisplayName, + animateEmojis + ) + binding.notificationRequestDisplayName.text = emojifiedName + val formattedUsername = context.getString(R.string.post_username_format, account.username) + binding.notificationRequestUsername.text = formattedUsername + + binding.notificationRequestAccept.setOnClickListener { + onAcceptRequest(notificationRequest.id) + } + binding.notificationRequestDismiss.setOnClickListener { + onDismissRequest(notificationRequest.id) + } + binding.root.setOnClickListener { + onOpenDetails(notificationRequest) + } + } + } + + companion object { + val NOTIFICATION_REQUEST_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: NotificationRequest, newItem: NotificationRequest): Boolean = + oldItem.id == newItem.id + override fun areContentsTheSame(oldItem: NotificationRequest, newItem: NotificationRequest): Boolean = + oldItem == newItem + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsPagingSource.kt new file mode 100644 index 000000000..b0f74bfa4 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsPagingSource.kt @@ -0,0 +1,35 @@ +/* Copyright 2024 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.requests + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.keylesspalace.tusky.entity.NotificationRequest + +class NotificationRequestsPagingSource( + private val requests: List, + private val nextKey: String? +) : PagingSource() { + override fun getRefreshKey(state: PagingState): String? = null + + override suspend fun load(params: LoadParams): LoadResult { + return if (params is LoadParams.Refresh) { + LoadResult.Page(requests.toList(), null, nextKey) + } else { + LoadResult.Page(emptyList(), null, null) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsRemoteMediator.kt new file mode 100644 index 000000000..e1dd8834c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsRemoteMediator.kt @@ -0,0 +1,73 @@ +/* Copyright 2024 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.requests + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import com.keylesspalace.tusky.entity.NotificationRequest +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.HttpHeaderLink +import retrofit2.HttpException +import retrofit2.Response + +@OptIn(ExperimentalPagingApi::class) +class NotificationRequestsRemoteMediator( + private val api: MastodonApi, + private val viewModel: NotificationRequestsViewModel +) : RemoteMediator() { + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + return try { + val response = request(loadType) + ?: return MediatorResult.Success(endOfPaginationReached = true) + + return applyResponse(response) + } catch (e: Exception) { + MediatorResult.Error(e) + } + } + + private suspend fun request(loadType: LoadType): Response>? { + return when (loadType) { + LoadType.PREPEND -> null + LoadType.APPEND -> api.getNotificationRequests(maxId = viewModel.nextKey) + LoadType.REFRESH -> { + viewModel.nextKey = null + viewModel.requestData.clear() + api.getNotificationRequests() + } + } + } + + private fun applyResponse(response: Response>): MediatorResult { + val notificationRequests = response.body() + if (!response.isSuccessful || notificationRequests == null) { + return MediatorResult.Error(HttpException(response)) + } + + val links = HttpHeaderLink.parse(response.headers()["Link"]) + viewModel.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id") + viewModel.requestData.addAll(notificationRequests) + viewModel.currentSource?.invalidate() + + return MediatorResult.Success(endOfPaginationReached = viewModel.nextKey == null) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsViewModel.kt new file mode 100644 index 000000000..f7be0dc13 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsViewModel.kt @@ -0,0 +1,132 @@ +/* Copyright 2024 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.requests + +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.appstore.BlockEvent +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.MuteEvent +import com.keylesspalace.tusky.entity.NotificationRequest +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.usecase.NotificationPolicyUsecase +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class NotificationRequestsViewModel @Inject constructor( + private val api: MastodonApi, + private val eventHub: EventHub, + private val notificationPolicyUsecase: NotificationPolicyUsecase +) : ViewModel() { + + var currentSource: NotificationRequestsPagingSource? = null + + val requestData: MutableList = mutableListOf() + + var nextKey: String? = null + + @OptIn(ExperimentalPagingApi::class) + val pager = Pager( + config = PagingConfig( + pageSize = 20, + initialLoadSize = 20 + ), + remoteMediator = NotificationRequestsRemoteMediator(api, this), + pagingSourceFactory = { + NotificationRequestsPagingSource( + requests = requestData, + nextKey = nextKey + ).also { source -> + currentSource = source + } + } + ).flow + .cachedIn(viewModelScope) + + private val _error = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val error: SharedFlow = _error.asSharedFlow() + + init { + viewModelScope.launch { + eventHub.events + .collect { event -> + when (event) { + is BlockEvent -> removeAllByAccount(event.accountId) + is MuteEvent -> removeAllByAccount(event.accountId) + } + } + } + } + + fun acceptNotificationRequest(id: String) { + viewModelScope.launch { + api.acceptNotificationRequest(id).fold({ + removeNotificationRequest(id) + }, { error -> + Log.w(TAG, "failed to dismiss notifications request", error) + _error.emit(error) + }) + } + } + + fun dismissNotificationRequest(id: String) { + viewModelScope.launch { + api.dismissNotificationRequest(id).fold({ + removeNotificationRequest(id) + }, { error -> + Log.w(TAG, "failed to dismiss notifications request", error) + _error.emit(error) + }) + } + } + + fun removeNotificationRequest(id: String) { + requestData.forEach { request -> + if (request.id == id) { + viewModelScope.launch { + notificationPolicyUsecase.updateCounts(request.notificationsCount) + } + } + } + requestData.removeAll { request -> request.id == id } + currentSource?.invalidate() + } + + private fun removeAllByAccount(accountId: String) { + requestData.removeAll { request -> request.account.id == accountId } + currentSource?.invalidate() + } + + companion object { + private const val TAG = "NotificationRequestsViewModel" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsActivity.kt new file mode 100644 index 000000000..64eda6a61 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsActivity.kt @@ -0,0 +1,114 @@ +/* Copyright 2024 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.requests.details + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat.Type.systemBars +import androidx.core.view.updatePadding +import androidx.lifecycle.lifecycleScope +import com.keylesspalace.tusky.BottomSheetActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ActivityNotificationRequestDetailsBinding +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.getParcelableArrayListExtraCompat +import com.keylesspalace.tusky.util.viewBinding +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.withCreationCallback +import kotlin.getValue +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class NotificationRequestDetailsActivity : BottomSheetActivity() { + + private val viewModel: NotificationRequestDetailsViewModel by viewModels( + extrasProducer = { + defaultViewModelCreationExtras.withCreationCallback { factory -> + factory.create( + notificationRequestId = intent.getStringExtra(EXTRA_NOTIFICATION_REQUEST_ID)!!, + accountId = intent.getStringExtra(EXTRA_ACCOUNT_ID)!! + ) + } + } + ) + + private val binding by viewBinding(ActivityNotificationRequestDetailsBinding::inflate) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(binding.root) + + setSupportActionBar(binding.includedToolbar.toolbar) + + val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + + val emojis: List = intent.getParcelableArrayListExtraCompat(EXTRA_ACCOUNT_EMOJIS)!! + + val title = getString(R.string.notifications_from, intent.getStringExtra(EXTRA_ACCOUNT_NAME)) + .emojify(emojis, binding.includedToolbar.toolbar, animateEmojis) + + supportActionBar?.run { + setTitle(title) + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets -> + val bottomInsets = insets.getInsets(systemBars()).bottom + view.updatePadding(bottom = bottomInsets) + insets.inset(0, 0, 0, bottomInsets) + } + + lifecycleScope.launch { + viewModel.finish.collect { finishMode -> + setResult(RESULT_OK, Intent().apply { putExtra(EXTRA_NOTIFICATION_REQUEST_ID, intent.getStringExtra(EXTRA_NOTIFICATION_REQUEST_ID)!!) }) + finish() + } + } + + binding.acceptButton.setOnClickListener { + viewModel.acceptNotificationRequest() + } + binding.dismissButton.setOnClickListener { + viewModel.dismissNotificationRequest() + } + } + + companion object { + const val EXTRA_NOTIFICATION_REQUEST_ID = "notificationRequestId" + private const val EXTRA_ACCOUNT_ID = "accountId" + private const val EXTRA_ACCOUNT_NAME = "accountName" + private const val EXTRA_ACCOUNT_EMOJIS = "accountEmojis" + fun newIntent( + notificationRequestId: String, + accountId: String, + accountName: String, + accountEmojis: List, + context: Context + ) = Intent(context, NotificationRequestDetailsActivity::class.java).apply { + putExtra(EXTRA_NOTIFICATION_REQUEST_ID, notificationRequestId) + putExtra(EXTRA_ACCOUNT_ID, accountId) + putExtra(EXTRA_ACCOUNT_NAME, accountName) + putExtra(EXTRA_ACCOUNT_EMOJIS, ArrayList(accountEmojis)) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsFragment.kt new file mode 100644 index 000000000..30eee5747 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsFragment.kt @@ -0,0 +1,299 @@ +/* Copyright 2024 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.requests.details + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.os.Bundle +import android.util.Log +import android.view.View +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.paging.LoadState +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.SimpleItemAnimator +import at.connyduck.calladapter.networkresult.onFailure +import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.notifications.NotificationActionListener +import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter +import com.keylesspalace.tusky.databinding.FragmentNotificationRequestDetailsBinding +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.fragment.SFragment +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.CardViewMode +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.getErrorString +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.openLink +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.util.visible +import com.keylesspalace.tusky.viewdata.AttachmentViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlin.getValue +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class NotificationRequestDetailsFragment : SFragment(R.layout.fragment_notification_request_details), StatusActionListener, NotificationActionListener, AccountActionListener { + + @Inject + lateinit var preferences: SharedPreferences + + private val viewModel: NotificationRequestDetailsViewModel by activityViewModels() + + private val binding by viewBinding(FragmentNotificationRequestDetailsBinding::bind) + + private var adapter: NotificationsPagingAdapter? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupAdapter().let { adapter -> + this.adapter = adapter + setupRecyclerView(adapter) + + lifecycleScope.launch { + viewModel.pager.collectLatest { pagingData -> + adapter.submitData(pagingData) + } + } + } + + lifecycleScope.launch { + viewModel.error.collect { error -> + Snackbar.make( + binding.root, + error.getErrorString(requireContext()), + LENGTH_LONG + ).show() + } + } + } + + private fun setupRecyclerView(adapter: NotificationsPagingAdapter) { + binding.recyclerView.adapter = adapter + binding.recyclerView.setHasFixedSize(true) + binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerView.addItemDecoration( + DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL) + ) + (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + } + + private fun setupAdapter(): NotificationsPagingAdapter { + val activeAccount = accountManager.activeAccount!! + val statusDisplayOptions = StatusDisplayOptions( + animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), + mediaPreviewEnabled = activeAccount.mediaPreviewEnabled, + useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), + showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), + useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), + cardViewMode = if (preferences.getBoolean(PrefKeys.SHOW_CARDS_IN_TIMELINES, false)) { + CardViewMode.INDENTED + } else { + CardViewMode.NONE + }, + confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), + confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), + showSensitiveMedia = activeAccount.alwaysShowSensitiveMedia, + openSpoiler = activeAccount.alwaysOpenSpoiler + ) + + return NotificationsPagingAdapter( + accountId = activeAccount.accountId, + statusDisplayOptions = statusDisplayOptions, + statusListener = this, + notificationActionListener = this, + accountActionListener = this, + instanceName = activeAccount.domain + ).apply { + addLoadStateListener { loadState -> + binding.progressBar.visible( + loadState.refresh == LoadState.Loading && itemCount == 0 + ) + + if (loadState.refresh is LoadState.Error) { + binding.recyclerView.hide() + binding.statusView.show() + val errorState = loadState.refresh as LoadState.Error + binding.statusView.setup(errorState.error) { retry() } + Log.w(TAG, "error loading notifications for user ${viewModel.accountId}", errorState.error) + } else { + binding.recyclerView.show() + binding.statusView.hide() + } + } + } + } + + override fun onReply(position: Int) { + val status = adapter?.peek(position)?.asStatusOrNull() ?: return + super.reply(status.status) + } + + override fun removeItem(position: Int) { + val notification = adapter?.peek(position) ?: return + viewModel.remove(notification) + } + + override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) { + val status = adapter?.peek(position)?.asStatusOrNull() ?: return + viewModel.reblog(reblog, status, visibility) + } + + override val onMoreTranslate: ((Boolean, Int) -> Unit)? + get() = { translate: Boolean, position: Int -> + if (translate) { + onTranslate(position) + } else { + onUntranslate(position) + } + } + + override fun onFavourite(favourite: Boolean, position: Int) { + val status = adapter?.peek(position)?.asStatusOrNull() ?: return + viewModel.favorite(favourite, status) + } + + override fun onBookmark(bookmark: Boolean, position: Int) { + val status = adapter?.peek(position)?.asStatusOrNull() ?: return + viewModel.bookmark(bookmark, status) + } + + override fun onMore(view: View, position: Int) { + val status = adapter?.peek(position)?.asStatusOrNull() ?: return + super.more( + status.status, + view, + position, + (status.translation as? TranslationViewData.Loaded)?.data + ) + } + + override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { + val status = adapter?.peek(position)?.asStatusOrNull() ?: return + super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view) + } + + override fun onViewThread(position: Int) { + val status = adapter?.peek(position)?.asStatusOrNull()?.status ?: return + super.viewThread(status.id, status.url) + } + + override fun onOpenReblog(position: Int) { + val status = adapter?.peek(position)?.asStatusOrNull() ?: return + super.openReblog(status.status) + } + + override fun onExpandedChange(expanded: Boolean, position: Int) { + val status = adapter?.peek(position)?.asStatusOrNull() ?: return + viewModel.changeExpanded(expanded, status) + } + + override fun onContentHiddenChange(isShowing: Boolean, position: Int) { + val status = adapter?.peek(position)?.asStatusOrNull() ?: return + viewModel.changeContentShowing(isShowing, status) + } + + override fun onLoadMore(position: Int) { + // not applicable here + } + + override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { + val status = adapter?.peek(position)?.asStatusOrNull() ?: return + viewModel.changeContentCollapsed(isCollapsed, status) + } + + override fun onVoteInPoll(position: Int, choices: List) { + val status = adapter?.peek(position)?.asStatusOrNull() ?: return + viewModel.voteInPoll(choices, status) + } + + override fun clearWarningAction(position: Int) { + // not applicable here + } + + private fun onTranslate(position: Int) { + val status = adapter?.peek(position)?.asStatusOrNull() ?: return + viewLifecycleOwner.lifecycleScope.launch { + viewModel.translate(status) + .onFailure { + Snackbar.make( + requireView(), + getString(R.string.ui_error_translate, it.message), + LENGTH_LONG + ).show() + } + } + } + + override fun onUntranslate(position: Int) { + val status = adapter?.peek(position)?.asStatusOrNull() ?: return + viewModel.untranslate(status) + } + + override fun onViewTag(tag: String) { + super.viewTag(tag) + } + + override fun onViewAccount(id: String) { + super.viewAccount(id) + } + + override fun onViewReport(reportId: String) { + requireContext().openLink( + "https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId" + ) + } + + override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { + // not needed, muting via the more menu on statuses is handled in SFragment + } + + override fun onBlock(block: Boolean, id: String, position: Int) { + // not needed, blocking via the more menu on statuses is handled in SFragment + } + + override fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) { + val notification = adapter?.peek(position) ?: return + viewModel.respondToFollowRequest(accept, accountId = id, notification = notification) + } + + override fun onDestroyView() { + adapter = null + super.onDestroyView() + } + + companion object { + private const val TAG = "NotificationRequestsDetailsFragment" + private const val EXTRA_ACCOUNT_ID = "accountId" + fun newIntent(accountId: String, context: Context) = Intent(context, NotificationRequestDetailsActivity::class.java).apply { + putExtra(EXTRA_ACCOUNT_ID, accountId) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsPagingSource.kt new file mode 100644 index 000000000..a7cb903a7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsPagingSource.kt @@ -0,0 +1,35 @@ +/* Copyright 2024 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.requests.details + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.keylesspalace.tusky.viewdata.NotificationViewData + +class NotificationRequestDetailsPagingSource( + private val notifications: List, + private val nextKey: String? +) : PagingSource() { + override fun getRefreshKey(state: PagingState): String? = null + + override suspend fun load(params: LoadParams): LoadResult { + return if (params is LoadParams.Refresh) { + LoadResult.Page(notifications.toList(), null, nextKey) + } else { + LoadResult.Page(emptyList(), null, null) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsRemoteMediator.kt new file mode 100644 index 000000000..c1f8ca984 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsRemoteMediator.kt @@ -0,0 +1,84 @@ +/* Copyright 2024 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.requests.details + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import com.keylesspalace.tusky.components.notifications.toViewData +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.util.HttpHeaderLink +import com.keylesspalace.tusky.viewdata.NotificationViewData +import retrofit2.HttpException +import retrofit2.Response + +@OptIn(ExperimentalPagingApi::class) +class NotificationRequestDetailsRemoteMediator( + private val viewModel: NotificationRequestDetailsViewModel +) : RemoteMediator() { + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + return try { + val response = request(loadType) + ?: return MediatorResult.Success(endOfPaginationReached = true) + + return applyResponse(response) + } catch (e: Exception) { + MediatorResult.Error(e) + } + } + + private suspend fun request(loadType: LoadType): Response>? { + return when (loadType) { + LoadType.PREPEND -> null + LoadType.APPEND -> viewModel.api.notifications(maxId = viewModel.nextKey, accountId = viewModel.accountId) + LoadType.REFRESH -> { + viewModel.nextKey = null + viewModel.notificationData.clear() + viewModel.api.notifications(accountId = viewModel.accountId) + } + } + } + + private fun applyResponse(response: Response>): MediatorResult { + val notifications = response.body() + if (!response.isSuccessful || notifications == null) { + return MediatorResult.Error(HttpException(response)) + } + + val links = HttpHeaderLink.parse(response.headers()["Link"]) + viewModel.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id") + + val alwaysShowSensitiveMedia = viewModel.accountManager.activeAccount?.alwaysShowSensitiveMedia == true + val alwaysOpenSpoiler = viewModel.accountManager.activeAccount?.alwaysOpenSpoiler == false + val notificationData = notifications.map { notification -> + notification.toViewData( + isShowingContent = alwaysShowSensitiveMedia, + isExpanded = alwaysOpenSpoiler, + true + ) + } + + viewModel.notificationData.addAll(notificationData) + viewModel.currentSource?.invalidate() + + return MediatorResult.Success(endOfPaginationReached = viewModel.nextKey == null) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsViewModel.kt new file mode 100644 index 000000000..c06c1c726 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsViewModel.kt @@ -0,0 +1,268 @@ +/* Copyright 2024 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.requests.details + +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.NetworkResult +import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.map +import at.connyduck.calladapter.networkresult.onFailure +import com.keylesspalace.tusky.appstore.BlockEvent +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.MuteEvent +import com.keylesspalace.tusky.appstore.StatusChangedEvent +import com.keylesspalace.tusky.components.timeline.util.ifExpected +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +@HiltViewModel(assistedFactory = NotificationRequestDetailsViewModel.Factory::class) +class NotificationRequestDetailsViewModel @AssistedInject constructor( + val api: MastodonApi, + val accountManager: AccountManager, + val timelineCases: TimelineCases, + val eventHub: EventHub, + @Assisted("notificationRequestId") val notificationRequestId: String, + @Assisted("accountId") val accountId: String +) : ViewModel() { + + var currentSource: NotificationRequestDetailsPagingSource? = null + + val notificationData: MutableList = mutableListOf() + + var nextKey: String? = null + + @OptIn(ExperimentalPagingApi::class) + val pager = Pager( + config = PagingConfig( + pageSize = 20, + initialLoadSize = 20 + ), + remoteMediator = NotificationRequestDetailsRemoteMediator(this), + pagingSourceFactory = { + NotificationRequestDetailsPagingSource( + notifications = notificationData, + nextKey = nextKey + ).also { source -> + currentSource = source + } + } + ).flow + .cachedIn(viewModelScope) + + private val _error = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val error: SharedFlow = _error.asSharedFlow() + + private val _finish = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val finish: SharedFlow = _finish.asSharedFlow() + + init { + viewModelScope.launch { + eventHub.events + .collect { event -> + when (event) { + is StatusChangedEvent -> updateStatus(event.status) + is BlockEvent -> removeIfAccount(event.accountId) + is MuteEvent -> removeIfAccount(event.accountId) + } + } + } + } + + fun acceptNotificationRequest() { + viewModelScope.launch { + api.acceptNotificationRequest(notificationRequestId).fold( + { + _finish.emit(Unit) + }, + { error -> + Log.w(TAG, "failed to dismiss notifications request", error) + _error.emit(error) + } + ) + } + } + + fun dismissNotificationRequest() { + viewModelScope.launch { + api.dismissNotificationRequest(notificationRequestId).fold({ + _finish.emit(Unit) + }, { error -> + Log.w(TAG, "failed to dismiss notifications request", error) + _error.emit(error) + }) + } + } + + private fun updateStatus(status: Status) { + val position = notificationData.indexOfFirst { it.asStatusOrNull()?.id == status.id } + if (position == -1) { + return + } + val viewData = notificationData[position].statusViewData?.copy(status = status) + notificationData[position] = notificationData[position].copy(statusViewData = viewData) + currentSource?.invalidate() + } + + private fun removeIfAccount(accountId: String) { + // if the account we are displaying notifications from got blocked or muted, we can exit + if (accountId == this.accountId) { + viewModelScope.launch { + _finish.emit(Unit) + } + } + } + + fun remove(notification: NotificationViewData) { + notificationData.remove(notification) + currentSource?.invalidate() + } + + fun reblog(reblog: Boolean, status: StatusViewData.Concrete, visibility: Status.Visibility = Status.Visibility.PUBLIC) = viewModelScope.launch { + timelineCases.reblog(status.actionableId, reblog, visibility).onFailure { t -> + ifExpected(t) { + Log.w(TAG, "Failed to reblog status " + status.actionableId, t) + } + } + } + + fun favorite(favorite: Boolean, status: StatusViewData.Concrete) = viewModelScope.launch { + timelineCases.favourite(status.actionableId, favorite).onFailure { t -> + ifExpected(t) { + Log.w(TAG, "Failed to favourite status " + status.actionableId, t) + } + } + } + + fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete) = viewModelScope.launch { + timelineCases.bookmark(status.actionableId, bookmark).onFailure { t -> + ifExpected(t) { + Log.w(TAG, "Failed to favourite status " + status.actionableId, t) + } + } + } + + fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { + updateStatusViewData(status.id) { it.copy(isExpanded = expanded) } + } + + fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { + updateStatusViewData(status.id) { it.copy(isShowingContent = isShowing) } + } + + fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { + updateStatusViewData(status.id) { it.copy(isCollapsed = isCollapsed) } + } + + fun voteInPoll(choices: List, status: StatusViewData.Concrete) = viewModelScope.launch { + val poll = status.status.actionableStatus.poll ?: run { + Log.w(TAG, "No poll on status ${status.id}") + return@launch + } + timelineCases.voteInPoll(status.actionableId, poll.id, choices).onFailure { t -> + ifExpected(t) { + Log.w(TAG, "Failed to vote in poll: " + status.actionableId, t) + } + } + } + + suspend fun translate(status: StatusViewData.Concrete): NetworkResult { + updateStatusViewData(status.id) { viewData -> + viewData.copy(translation = TranslationViewData.Loading) + } + return timelineCases.translate(status.actionableId) + .map { translation -> + updateStatusViewData(status.id) { viewData -> + viewData.copy(translation = TranslationViewData.Loaded(translation)) + } + } + .onFailure { + updateStatusViewData(status.id) { viewData -> + viewData.copy(translation = null) + } + } + } + + fun untranslate(status: StatusViewData.Concrete) { + updateStatusViewData(status.id) { it.copy(translation = null) } + } + + fun respondToFollowRequest(accept: Boolean, accountId: String, notification: NotificationViewData) { + viewModelScope.launch { + if (accept) { + api.authorizeFollowRequest(accountId) + } else { + api.rejectFollowRequest(accountId) + }.fold( + onSuccess = { + // since the follow request has been responded, the notification can be deleted + remove(notification) + }, + onFailure = { t -> + Log.w(TAG, "Failed to to respond to follow request from account id $accountId.", t) + } + ) + } + } + + private fun updateStatusViewData( + statusId: String, + updater: (StatusViewData.Concrete) -> StatusViewData.Concrete + ) { + val position = notificationData.indexOfFirst { it.asStatusOrNull()?.id == statusId } + val statusViewData = notificationData.getOrNull(position)?.statusViewData ?: return + notificationData[position] = notificationData[position].copy(statusViewData = updater(statusViewData)) + currentSource?.invalidate() + } + + companion object { + private const val TAG = "NotificationRequestsViewModel" + } + + @AssistedFactory interface Factory { + fun create( + @Assisted("notificationRequestId") notificationRequestId: String, + @Assisted("accountId") accountId: String + ): NotificationRequestDetailsViewModel + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt index b9f77a1db..d48f92d0e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -17,11 +17,13 @@ package com.keylesspalace.tusky.components.preference import android.content.Intent import android.graphics.Color +import android.graphics.drawable.Drawable import android.os.Build import android.os.Bundle import android.util.Log -import androidx.annotation.DrawableRes -import androidx.preference.PreferenceFragmentCompat +import androidx.lifecycle.lifecycleScope +import androidx.preference.ListPreference +import at.connyduck.calladapter.networkresult.fold import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity @@ -29,18 +31,18 @@ import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.R import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.domainblocks.DomainBlocksActivity import com.keylesspalace.tusky.components.filters.FiltersActivity import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity -import com.keylesspalace.tusky.components.login.LoginActivity -import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigration +import com.keylesspalace.tusky.components.preference.notificationpolicies.NotificationPoliciesActivity import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.settings.AccountPreferenceDataStore +import com.keylesspalace.tusky.settings.DefaultReplyVisibility import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.listPreference import com.keylesspalace.tusky.settings.makePreferenceScreen @@ -50,19 +52,18 @@ import com.keylesspalace.tusky.settings.switchPreference import com.keylesspalace.tusky.util.getInitialLanguages import com.keylesspalace.tusky.util.getLocaleList import com.keylesspalace.tusky.util.getTuskyDisplayName -import com.keylesspalace.tusky.util.makeIcon +import com.keylesspalace.tusky.util.icon import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation -import com.keylesspalace.tusky.util.unsafeLazy import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeRes +import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response +import kotlinx.coroutines.launch -class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { +@AndroidEntryPoint +class AccountPreferencesFragment : BasePreferencesFragment() { @Inject lateinit var accountManager: AccountManager @@ -75,12 +76,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { @Inject lateinit var accountPreferenceDataStore: AccountPreferenceDataStore - private val iconSize by unsafeLazy { - resources.getDimensionPixelSize( - R.dimen.preference_icon_size - ) - } - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { val context = requireContext() makePreferenceScreen { @@ -98,7 +93,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { preference { setTitle(R.string.title_tab_preferences) - setIcon(R.drawable.ic_tabs) + icon = icon(R.drawable.ic_tabs) setOnPreferenceClickListener { val intent = Intent(context, TabPreferenceActivity::class.java) activity?.startActivityWithSlideInAnimation(intent) @@ -108,7 +103,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { preference { setTitle(R.string.title_followed_hashtags) - setIcon(R.drawable.ic_hashtag) + icon = icon(R.drawable.ic_hashtag) setOnPreferenceClickListener { val intent = Intent(context, FollowedTagsActivity::class.java) activity?.startActivityWithSlideInAnimation(intent) @@ -118,7 +113,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { preference { setTitle(R.string.action_view_mutes) - setIcon(R.drawable.ic_mute_24dp) + icon = icon(R.drawable.ic_mute_24dp) setOnPreferenceClickListener { val intent = Intent(context, AccountListActivity::class.java) intent.putExtra("type", AccountListActivity.Type.MUTES) @@ -129,10 +124,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { preference { setTitle(R.string.action_view_blocks) - icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_block).apply { - sizeRes = R.dimen.preference_icon_size - colorInt = MaterialColors.getColor(context, R.attr.iconColor, Color.BLACK) - } + icon = icon(GoogleMaterial.Icon.gmd_block) setOnPreferenceClickListener { val intent = Intent(context, AccountListActivity::class.java) intent.putExtra("type", AccountListActivity.Type.BLOCKS) @@ -143,7 +135,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { preference { setTitle(R.string.title_domain_mutes) - setIcon(R.drawable.ic_mute_24dp) + icon = icon(R.drawable.ic_mute_24dp) setOnPreferenceClickListener { val intent = Intent(context, DomainBlocksActivity::class.java) activity?.startActivityWithSlideInAnimation(intent) @@ -151,21 +143,9 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } } - if (currentAccountNeedsMigration(accountManager)) { - preference { - setTitle(R.string.title_migration_relogin) - setIcon(R.drawable.ic_logout) - setOnPreferenceClickListener { - val intent = LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION) - activity?.startActivityWithSlideInAnimation(intent) - true - } - } - } - preference { setTitle(R.string.pref_title_timeline_filters) - setIcon(R.drawable.ic_filter_24dp) + icon = icon(R.drawable.ic_filter_24dp) setOnPreferenceClickListener { launchFilterActivity() true @@ -180,17 +160,50 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { key = PrefKeys.DEFAULT_POST_PRIVACY setSummaryProvider { entry } val visibility = accountManager.activeAccount?.defaultPostPrivacy ?: Status.Visibility.PUBLIC - value = visibility.serverString - setIcon(getIconForVisibility(visibility)) + value = visibility.stringValue + icon = getIconForVisibility(visibility) + isPersistent = false // its saved to the account and shouldn't be in shared preferences setOnPreferenceChangeListener { _, newValue -> - setIcon( - getIconForVisibility(Status.Visibility.byString(newValue as String)) - ) + icon = getIconForVisibility(Status.Visibility.fromStringValue(newValue as String)) + if (accountManager.activeAccount?.defaultReplyPrivacy == DefaultReplyVisibility.MATCH_DEFAULT_POST_VISIBILITY) { + findPreference(PrefKeys.DEFAULT_REPLY_PRIVACY)?.icon = icon + } syncWithServer(visibility = newValue) true } } + val activeAccount = accountManager.activeAccount + if (activeAccount != null) { + listPreference { + setTitle(R.string.pref_default_reply_privacy) + setEntries(R.array.reply_privacy_names) + setEntryValues(R.array.reply_privacy_values) + key = PrefKeys.DEFAULT_REPLY_PRIVACY + setSummaryProvider { entry } + val visibility = activeAccount.defaultReplyPrivacy + value = visibility.stringValue + icon = getIconForVisibility(visibility.toVisibilityOr(activeAccount.defaultPostPrivacy)) + isPersistent = false // its saved to the account and shouldn't be in shared preferences + setOnPreferenceChangeListener { _, newValue -> + val newVisibility = DefaultReplyVisibility.fromStringValue(newValue as String) + + icon = getIconForVisibility(newVisibility.toVisibilityOr(activeAccount.defaultPostPrivacy)) + + viewLifecycleOwner.lifecycleScope.launch { + accountManager.updateAccount(activeAccount) { copy(defaultReplyPrivacy = newVisibility) } + eventHub.dispatch(PreferenceChangedEvent(key)) + } + true + } + } + preference { + setSummary(R.string.pref_default_reply_privacy_explanation) + shouldDisableView = false + isEnabled = false + } + } + listPreference { val locales = getLocaleList(getInitialLanguages(null, accountManager.activeAccount)) @@ -203,7 +216,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { ).toTypedArray() entryValues = (listOf("") + locales.map { it.language }).toTypedArray() key = PrefKeys.DEFAULT_POST_LANGUAGE - icon = makeIcon(requireContext(), GoogleMaterial.Icon.gmd_translate, iconSize) + icon = icon(GoogleMaterial.Icon.gmd_translate) value = accountManager.activeAccount?.defaultPostLanguage.orEmpty() isPersistent = false // This will be entirely server-driven setSummaryProvider { entry } @@ -216,14 +229,13 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { switchPreference { setTitle(R.string.pref_default_media_sensitivity) - setIcon(R.drawable.ic_eye_24dp) + icon = icon(R.drawable.ic_eye_24dp) key = PrefKeys.DEFAULT_MEDIA_SENSITIVITY - isSingleLineTitle = false - val sensitivity = accountManager.activeAccount?.defaultMediaSensitivity ?: false + val sensitivity = accountManager.activeAccount?.defaultMediaSensitivity == true setDefaultValue(sensitivity) - setIcon(getIconForSensitivity(sensitivity)) + icon = getIconForSensitivity(sensitivity) setOnPreferenceChangeListener { _, newValue -> - setIcon(getIconForSensitivity(newValue as Boolean)) + icon = getIconForSensitivity(newValue as Boolean) syncWithServer(sensitive = newValue) true } @@ -237,21 +249,18 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { switchPreference { key = PrefKeys.MEDIA_PREVIEW_ENABLED setTitle(R.string.pref_title_show_media_preview) - isSingleLineTitle = false preferenceDataStore = accountPreferenceDataStore } switchPreference { key = PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA setTitle(R.string.pref_title_alway_show_sensitive_media) - isSingleLineTitle = false preferenceDataStore = accountPreferenceDataStore } switchPreference { key = PrefKeys.ALWAYS_OPEN_SPOILER setTitle(R.string.pref_title_alway_open_spoiler) - isSingleLineTitle = false preferenceDataStore = accountPreferenceDataStore } } @@ -261,6 +270,16 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { fragment = TabFilterPreferencesFragment::class.qualifiedName } } + preference { + setTitle(R.string.notification_policies_title) + setOnPreferenceClickListener { + activity?.let { + val intent = NotificationPoliciesActivity.newIntent(it) + it.startActivityWithSlideInAnimation(intent) + } + true + } + } } } @@ -293,29 +312,24 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { ) { // TODO these could also be "datastore backed" preferences (a ServerPreferenceDataStore); follow-up of issue #3204 - mastodonApi.accountUpdateSource(visibility, sensitive, language) - .enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - val account = response.body() - if (response.isSuccessful && account != null) { - accountManager.activeAccount?.let { - it.defaultPostPrivacy = account.source?.privacy - ?: Status.Visibility.PUBLIC - it.defaultMediaSensitivity = account.source?.sensitive ?: false - it.defaultPostLanguage = language.orEmpty() - accountManager.saveAccount(it) + viewLifecycleOwner.lifecycleScope.launch { + mastodonApi.accountUpdateSource(visibility, sensitive, language) + .fold({ account: Account -> + accountManager.activeAccount?.let { + accountManager.updateAccount(it) { + copy( + defaultPostPrivacy = account.source?.privacy + ?: Status.Visibility.PUBLIC, + defaultMediaSensitivity = account.source?.sensitive == true, + defaultPostLanguage = language.orEmpty() + ) } - } else { - Log.e("AccountPreferences", "failed updating settings on server") - showErrorSnackbar(visibility, sensitive) } - } - - override fun onFailure(call: Call, t: Throwable) { + }, { t -> Log.e("AccountPreferences", "failed updating settings on server", t) showErrorSnackbar(visibility, sensitive) - } - }) + }) + } } private fun showErrorSnackbar(visibility: String?, sensitive: Boolean?) { @@ -326,23 +340,21 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } } - @DrawableRes - private fun getIconForVisibility(visibility: Status.Visibility): Int { - return when (visibility) { + private fun getIconForVisibility(visibility: Status.Visibility): Drawable? { + val iconRes = when (visibility) { Status.Visibility.PRIVATE -> R.drawable.ic_lock_outline_24dp - Status.Visibility.UNLISTED -> R.drawable.ic_lock_open_24dp - + Status.Visibility.DIRECT -> R.drawable.ic_email_24dp else -> R.drawable.ic_public_24dp } + return icon(iconRes) } - @DrawableRes - private fun getIconForSensitivity(sensitive: Boolean): Int { + private fun getIconForSensitivity(sensitive: Boolean): Drawable? { return if (sensitive) { - R.drawable.ic_hide_media_24dp + icon(R.drawable.ic_hide_media_24dp) } else { - R.drawable.ic_eye_24dp + icon(R.drawable.ic_eye_24dp) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/BasePreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/BasePreferencesFragment.kt new file mode 100644 index 000000000..ba452f0bb --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/BasePreferencesFragment.kt @@ -0,0 +1,20 @@ +package com.keylesspalace.tusky.components.preference + +import android.os.Bundle +import android.view.View +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat.Type.systemBars +import androidx.core.view.updatePadding +import androidx.preference.PreferenceFragmentCompat + +abstract class BasePreferencesFragment : PreferenceFragmentCompat() { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + ViewCompat.setOnApplyWindowInsetsListener(listView) { listView, insets -> + val systemBarsInsets = insets.getInsets(systemBars()) + listView.updatePadding(bottom = systemBarsInsets.bottom) + insets.inset(0, 0, 0, systemBarsInsets.bottom) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt index 83a96ed5b..fcc42e852 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt @@ -16,26 +16,30 @@ package com.keylesspalace.tusky.components.preference import android.os.Bundle -import androidx.preference.PreferenceFragmentCompat +import androidx.lifecycle.lifecycleScope import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.notifications.NotificationHelper -import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.components.systemnotifications.NotificationService import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.makePreferenceScreen import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.switchPreference +import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import kotlinx.coroutines.launch -class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable { +@AndroidEntryPoint +class NotificationPreferencesFragment : BasePreferencesFragment() { @Inject lateinit var accountManager: AccountManager + @Inject + lateinit var notificationService: NotificationService + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { val activeAccount = accountManager.activeAccount ?: return - val context = requireContext() makePreferenceScreen { switchPreference { setTitle(R.string.pref_title_notifications_enabled) @@ -43,11 +47,11 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable { isIconSpaceReserved = false isChecked = activeAccount.notificationsEnabled setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsEnabled = newValue as Boolean } - if (NotificationHelper.areNotificationsEnabled(context, accountManager)) { - NotificationHelper.enablePullNotifications(context) + updateAccount { copy(notificationsEnabled = newValue as Boolean) } + if (notificationService.areNotificationsEnabledBySystem()) { + notificationService.enablePullNotifications() } else { - NotificationHelper.disablePullNotifications(context) + notificationService.disablePullNotifications() } true } @@ -58,100 +62,100 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable { category.isIconSpaceReserved = false switchPreference { - setTitle(R.string.pref_title_notification_filter_follows) - key = PrefKeys.NOTIFICATIONS_FILTER_FOLLOWS + setTitle(R.string.notification_follow_name) + setSummary(R.string.notification_follow_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsFollowed setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsFollowed = newValue as Boolean } + updateAccount { copy(notificationsFollowed = newValue as Boolean) } true } } switchPreference { - setTitle(R.string.pref_title_notification_filter_follow_requests) - key = PrefKeys.NOTIFICATION_FILTER_FOLLOW_REQUESTS + setTitle(R.string.notification_follow_request_name) + setSummary(R.string.notification_follow_request_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsFollowRequested setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsFollowRequested = newValue as Boolean } + updateAccount { copy(notificationsFollowRequested = newValue as Boolean) } true } } switchPreference { - setTitle(R.string.pref_title_notification_filter_reblogs) - key = PrefKeys.NOTIFICATION_FILTER_REBLOGS + setTitle(R.string.notification_boost_name) + setSummary(R.string.notification_boost_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsReblogged setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsReblogged = newValue as Boolean } + updateAccount { copy(notificationsReblogged = newValue as Boolean) } true } } switchPreference { - setTitle(R.string.pref_title_notification_filter_favourites) - key = PrefKeys.NOTIFICATION_FILTER_FAVS + setTitle(R.string.notification_favourite_name) + setSummary(R.string.notification_favourite_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsFavorited setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsFavorited = newValue as Boolean } + updateAccount { copy(notificationsFavorited = newValue as Boolean) } true } } switchPreference { - setTitle(R.string.pref_title_notification_filter_poll) - key = PrefKeys.NOTIFICATION_FILTER_POLLS + setTitle(R.string.notification_poll_name) + setSummary(R.string.notification_poll_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsPolls setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsPolls = newValue as Boolean } + updateAccount { copy(notificationsPolls = newValue as Boolean) } true } } switchPreference { - setTitle(R.string.pref_title_notification_filter_subscriptions) - key = PrefKeys.NOTIFICATION_FILTER_SUBSCRIPTIONS + setTitle(R.string.notification_subscription_name) + setSummary(R.string.notification_subscription_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsSubscriptions setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsSubscriptions = newValue as Boolean } + updateAccount { copy(notificationsSubscriptions = newValue as Boolean) } true } } switchPreference { - setTitle(R.string.pref_title_notification_filter_sign_ups) - key = PrefKeys.NOTIFICATION_FILTER_SIGN_UPS - isIconSpaceReserved = false - isChecked = activeAccount.notificationsSignUps - setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsSignUps = newValue as Boolean } - true - } - } - - switchPreference { - setTitle(R.string.pref_title_notification_filter_updates) - key = PrefKeys.NOTIFICATION_FILTER_UPDATES + setTitle(R.string.notification_update_name) + setSummary(R.string.notification_update_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsUpdates setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsUpdates = newValue as Boolean } + updateAccount { copy(notificationsUpdates = newValue as Boolean) } true } } switchPreference { - setTitle(R.string.pref_title_notification_filter_reports) - key = PrefKeys.NOTIFICATION_FILTER_REPORTS + setTitle(R.string.notification_channel_admin) + setSummary(R.string.notification_channel_admin_description) isIconSpaceReserved = false - isChecked = activeAccount.notificationsReports + isChecked = activeAccount.notificationsAdmin setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsReports = newValue as Boolean } + updateAccount { copy(notificationsAdmin = newValue as Boolean) } + true + } + } + + switchPreference { + setTitle(R.string.notification_channel_other) + setSummary(R.string.notification_channel_other_description) + isIconSpaceReserved = false + isChecked = activeAccount.notificationsOther + setOnPreferenceChangeListener { _, newValue -> + updateAccount { copy(notificationsOther = newValue as Boolean) } true } } @@ -167,7 +171,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable { isIconSpaceReserved = false isChecked = activeAccount.notificationSound setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationSound = newValue as Boolean } + updateAccount { copy(notificationSound = newValue as Boolean) } true } } @@ -178,7 +182,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable { isIconSpaceReserved = false isChecked = activeAccount.notificationVibration setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationVibration = newValue as Boolean } + updateAccount { copy(notificationVibration = newValue as Boolean) } true } } @@ -189,7 +193,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable { isIconSpaceReserved = false isChecked = activeAccount.notificationLight setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationLight = newValue as Boolean } + updateAccount { copy(notificationLight = newValue as Boolean) } true } } @@ -197,10 +201,11 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable { } } - private inline fun updateAccount(changer: (AccountEntity) -> Unit) { - accountManager.activeAccount?.let { account -> - changer(account) - accountManager.saveAccount(account) + private fun updateAccount(changer: AccountEntity.() -> AccountEntity) { + viewLifecycleOwner.lifecycleScope.launch { + accountManager.activeAccount?.let { account -> + accountManager.updateAccount(account, changer) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt index 3e82f01d2..9a71d34bd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt @@ -18,15 +18,16 @@ package com.keylesspalace.tusky.components.preference import android.content.Context import android.content.Intent import android.content.SharedPreferences +import android.os.Build import android.os.Bundle import android.util.Log import androidx.activity.OnBackPressedCallback +import androidx.core.view.WindowCompat import androidx.fragment.app.Fragment import androidx.fragment.app.commit import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat -import androidx.preference.PreferenceManager import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.R @@ -39,23 +40,19 @@ import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.setAppNightMode import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation -import dagger.android.DispatchingAndroidInjector -import dagger.android.HasAndroidInjector +import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.launch +@AndroidEntryPoint class PreferencesActivity : BaseActivity(), SharedPreferences.OnSharedPreferenceChangeListener, - PreferenceFragmentCompat.OnPreferenceStartFragmentCallback, - HasAndroidInjector { + PreferenceFragmentCompat.OnPreferenceStartFragmentCallback { @Inject lateinit var eventHub: EventHub - @Inject - lateinit var androidInjector: DispatchingAndroidInjector - private val restartActivitiesOnBackPressedCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { /* Switching themes won't actually change the theme of activities on the back stack. @@ -71,6 +68,12 @@ class PreferencesActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Workaround for edge-to-edge mode not working when an activity is recreated + // https://stackoverflow.com/questions/79319740/edge-to-edge-doesnt-work-when-activity-recreated-or-appcompatdelegate-setdefaul + if (savedInstanceState != null && Build.VERSION.SDK_INT >= 35) { + WindowCompat.setDecorFitsSystemWindows(window, false) + } + val binding = ActivityPreferencesBinding.inflate(layoutInflater) setContentView(binding.root) @@ -97,9 +100,7 @@ class PreferencesActivity : } onBackPressedDispatcher.addCallback(this, restartActivitiesOnBackPressedCallback) - restartActivitiesOnBackPressedCallback.isEnabled = intent.extras?.getBoolean( - EXTRA_RESTART_ON_BACK - ) ?: savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false + restartActivitiesOnBackPressedCallback.isEnabled = savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) == true } override fun onPreferenceStartFragment( @@ -112,7 +113,6 @@ class PreferencesActivity : pref.fragment!! ) fragment.arguments = args - fragment.setTargetFragment(caller, 0) supportFragmentManager.commit { setCustomAnimations( R.anim.activity_open_enter, @@ -128,16 +128,12 @@ class PreferencesActivity : override fun onResume() { super.onResume() - PreferenceManager.getDefaultSharedPreferences( - this - ).registerOnSharedPreferenceChangeListener(this) + preferences.registerOnSharedPreferenceChangeListener(this) } override fun onPause() { super.onPause() - PreferenceManager.getDefaultSharedPreferences( - this - ).unregisterOnSharedPreferenceChangeListener(this) + preferences.unregisterOnSharedPreferenceChangeListener(this) } override fun onSaveInstanceState(outState: Bundle) { @@ -172,8 +168,6 @@ class PreferencesActivity : } } - override fun androidInjector() = androidInjector - companion object { @Suppress("unused") private const val TAG = "PreferencesActivity" diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt index a7dc4e92e..845ef7b5b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt @@ -16,12 +16,11 @@ package com.keylesspalace.tusky.components.preference import android.os.Bundle +import androidx.lifecycle.lifecycleScope import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.settings.AppTheme import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.emojiPreference @@ -32,16 +31,15 @@ import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.sliderPreference import com.keylesspalace.tusky.settings.switchPreference import com.keylesspalace.tusky.util.LocaleManager -import com.keylesspalace.tusky.util.deserialize -import com.keylesspalace.tusky.util.makeIcon -import com.keylesspalace.tusky.util.serialize -import com.keylesspalace.tusky.util.unsafeLazy -import com.mikepenz.iconics.IconicsDrawable +import com.keylesspalace.tusky.util.icon import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import dagger.hilt.android.AndroidEntryPoint import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference import javax.inject.Inject +import kotlinx.coroutines.launch -class PreferencesFragment : PreferenceFragmentCompat(), Injectable { +@AndroidEntryPoint +class PreferencesFragment : BasePreferencesFragment() { @Inject lateinit var accountManager: AccountManager @@ -49,12 +47,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { @Inject lateinit var localeManager: LocaleManager - private val iconSize by unsafeLazy { - resources.getDimensionPixelSize( - R.dimen.preference_icon_size - ) - } - enum class ReadingOrder { /** User scrolls up, reading statuses oldest to newest */ OLDEST_FIRST, @@ -85,12 +77,12 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { key = PrefKeys.APP_THEME setSummaryProvider { entry } setTitle(R.string.pref_title_app_theme) - icon = makeIcon(GoogleMaterial.Icon.gmd_palette) + icon = icon(GoogleMaterial.Icon.gmd_palette) } emojiPreference(requireActivity()) { setTitle(R.string.emoji_style) - icon = makeIcon(GoogleMaterial.Icon.gmd_sentiment_satisfied) + icon = icon(GoogleMaterial.Icon.gmd_sentiment_satisfied) } listPreference { @@ -100,7 +92,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { key = PrefKeys.LANGUAGE + "_" // deliberately not the actual key, the real handling happens in LocaleManager setSummaryProvider { entry } setTitle(R.string.pref_title_language) - icon = makeIcon(GoogleMaterial.Icon.gmd_translate) + icon = icon(GoogleMaterial.Icon.gmd_translate) preferenceDataStore = localeManager } @@ -112,9 +104,9 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { stepSize = 5F setTitle(R.string.pref_ui_text_size) format = "%.0f%%" - decrementIcon = makeIcon(GoogleMaterial.Icon.gmd_zoom_out) - incrementIcon = makeIcon(GoogleMaterial.Icon.gmd_zoom_in) - icon = makeIcon(GoogleMaterial.Icon.gmd_format_size) + decrementIcon = icon(GoogleMaterial.Icon.gmd_zoom_out) + incrementIcon = icon(GoogleMaterial.Icon.gmd_zoom_in) + icon = icon(GoogleMaterial.Icon.gmd_format_size) } listPreference { @@ -124,7 +116,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { key = PrefKeys.STATUS_TEXT_SIZE setSummaryProvider { entry } setTitle(R.string.pref_post_text_size) - icon = makeIcon(GoogleMaterial.Icon.gmd_format_size) + icon = icon(GoogleMaterial.Icon.gmd_format_size) } listPreference { @@ -134,7 +126,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { key = PrefKeys.READING_ORDER setSummaryProvider { entry } setTitle(R.string.pref_title_reading_order) - icon = makeIcon(GoogleMaterial.Icon.gmd_sort) + icon = icon(GoogleMaterial.Icon.gmd_sort) } listPreference { @@ -153,7 +145,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { key = PrefKeys.SHOW_SELF_USERNAME setSummaryProvider { entry } setTitle(R.string.pref_title_show_self_username) - isSingleLineTitle = false } switchPreference { @@ -163,88 +154,76 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { } switchPreference { - setDefaultValue(false) - key = PrefKeys.FAB_HIDE - setTitle(R.string.pref_title_hide_follow_button) - isSingleLineTitle = false + setDefaultValue(true) + key = PrefKeys.SHOW_NOTIFICATIONS_FILTER + setTitle(R.string.pref_title_show_notifications_filter) } switchPreference { setDefaultValue(false) key = PrefKeys.ABSOLUTE_TIME_VIEW setTitle(R.string.pref_title_absolute_time) - isSingleLineTitle = false } switchPreference { setDefaultValue(true) key = PrefKeys.SHOW_BOT_OVERLAY setTitle(R.string.pref_title_bot_overlay) - isSingleLineTitle = false - setIcon(R.drawable.ic_bot_24dp) + icon = icon(R.drawable.ic_bot_24dp) } switchPreference { setDefaultValue(false) key = PrefKeys.ANIMATE_GIF_AVATARS setTitle(R.string.pref_title_animate_gif_avatars) - isSingleLineTitle = false } switchPreference { setDefaultValue(false) key = PrefKeys.ANIMATE_CUSTOM_EMOJIS setTitle(R.string.pref_title_animate_custom_emojis) - isSingleLineTitle = false } switchPreference { setDefaultValue(true) key = PrefKeys.USE_BLURHASH setTitle(R.string.pref_title_gradient_for_media) - isSingleLineTitle = false } switchPreference { setDefaultValue(false) key = PrefKeys.SHOW_CARDS_IN_TIMELINES setTitle(R.string.pref_title_show_cards_in_timelines) - isSingleLineTitle = false - } - - switchPreference { - setDefaultValue(true) - key = PrefKeys.SHOW_NOTIFICATIONS_FILTER - setTitle(R.string.pref_title_show_notifications_filter) - isSingleLineTitle = false } switchPreference { setDefaultValue(true) key = PrefKeys.CONFIRM_REBLOGS setTitle(R.string.pref_title_confirm_reblogs) - isSingleLineTitle = false } switchPreference { setDefaultValue(false) key = PrefKeys.CONFIRM_FAVOURITES setTitle(R.string.pref_title_confirm_favourites) - isSingleLineTitle = false + } + + switchPreference { + setDefaultValue(false) + key = PrefKeys.CONFIRM_FOLLOWS + setTitle(R.string.pref_title_confirm_follows) } switchPreference { setDefaultValue(true) key = PrefKeys.ENABLE_SWIPE_FOR_TABS setTitle(R.string.pref_title_enable_swipe_for_tabs) - isSingleLineTitle = false } switchPreference { setDefaultValue(false) key = PrefKeys.SHOW_STATS_INLINE setTitle(R.string.pref_title_show_stat_inline) - isSingleLineTitle = false } } @@ -253,7 +232,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { setDefaultValue(false) key = PrefKeys.CUSTOM_TABS setTitle(R.string.pref_title_custom_tabs) - isSingleLineTitle = false } } @@ -264,22 +242,21 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { key = PrefKeys.WELLBEING_LIMITED_NOTIFICATIONS setOnPreferenceChangeListener { _, value -> for (account in accountManager.accounts) { - val notificationFilter = deserialize( - account.notificationsFilter - ).toMutableSet() + val notificationFilter = account.notificationsFilter.toMutableSet() if (value == true) { - notificationFilter.add(Notification.Type.FAVOURITE) - notificationFilter.add(Notification.Type.FOLLOW) - notificationFilter.add(Notification.Type.REBLOG) + notificationFilter.add(NotificationChannelData.FAVOURITE) + notificationFilter.add(NotificationChannelData.FOLLOW) + notificationFilter.add(NotificationChannelData.REBLOG) } else { - notificationFilter.remove(Notification.Type.FAVOURITE) - notificationFilter.remove(Notification.Type.FOLLOW) - notificationFilter.remove(Notification.Type.REBLOG) + notificationFilter.remove(NotificationChannelData.FAVOURITE) + notificationFilter.remove(NotificationChannelData.FOLLOW) + notificationFilter.remove(NotificationChannelData.REBLOG) } - account.notificationsFilter = serialize(notificationFilter) - accountManager.saveAccount(account) + lifecycleScope.launch { + accountManager.updateAccount(account) { copy(notificationsFilter = notificationFilter) } + } } true } @@ -308,10 +285,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { } } - private fun makeIcon(icon: GoogleMaterial.Icon): IconicsDrawable { - return makeIcon(requireContext(), icon, iconSize) - } - override fun onResume() { super.onResume() requireActivity().setTitle(R.string.action_view_preferences) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt index 84f1336f1..fba6c8ba8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt @@ -17,7 +17,6 @@ package com.keylesspalace.tusky.components.preference import android.os.Bundle import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat import com.keylesspalace.tusky.R import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.ProxyConfiguration @@ -30,7 +29,7 @@ import com.keylesspalace.tusky.settings.validatedEditTextPreference import com.keylesspalace.tusky.util.getNonNullString import kotlin.system.exitProcess -class ProxyPreferencesFragment : PreferenceFragmentCompat() { +class ProxyPreferencesFragment : BasePreferencesFragment() { private var pendingRestart = false override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt index 93fe05cd0..6d9141c93 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt @@ -19,18 +19,18 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.preference.PreferenceFragmentCompat import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.settings.AccountPreferenceDataStore import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.makePreferenceScreen import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.switchPreference +import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -class TabFilterPreferencesFragment : PreferenceFragmentCompat(), Injectable { +@AndroidEntryPoint +class TabFilterPreferencesFragment : BasePreferencesFragment() { @Inject lateinit var accountPreferenceDataStore: AccountPreferenceDataStore diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesActivity.kt new file mode 100644 index 000000000..06d5b3490 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesActivity.kt @@ -0,0 +1,87 @@ +/* Copyright 2024 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.preference.notificationpolicies + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ActivityNotificationPolicyBinding +import com.keylesspalace.tusky.usecase.NotificationPolicyState +import com.keylesspalace.tusky.util.getErrorString +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.util.visible +import dagger.hilt.android.AndroidEntryPoint +import kotlin.getValue +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class NotificationPoliciesActivity : BaseActivity() { + + private val viewModel: NotificationPoliciesViewModel by viewModels() + + private val binding by viewBinding(ActivityNotificationPolicyBinding::inflate) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(binding.root) + + setSupportActionBar(binding.includedToolbar.toolbar) + supportActionBar?.run { + setTitle(R.string.notification_policies_title) + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + lifecycleScope.launch { + viewModel.state.collect { state -> + binding.progressBar.visible(state is NotificationPolicyState.Loading) + binding.preferenceFragment.visible(state is NotificationPolicyState.Loaded) + binding.messageView.visible(state !is NotificationPolicyState.Loading && state !is NotificationPolicyState.Loaded) + when (state) { + is NotificationPolicyState.Loading -> { } + + is NotificationPolicyState.Error -> + binding.messageView.setup(state.throwable) { viewModel.loadPolicy() } + + is NotificationPolicyState.Loaded -> { } + + NotificationPolicyState.Unsupported -> + binding.messageView.setup(R.drawable.errorphant_error, R.string.notification_policies_not_supported) { viewModel.loadPolicy() } + } + } + } + lifecycleScope.launch { + viewModel.error.collect { error -> + Snackbar.make( + binding.root, + error.getErrorString(this@NotificationPoliciesActivity), + LENGTH_LONG + ).show() + } + } + } + + companion object { + fun newIntent(context: Context) = Intent(context, NotificationPoliciesActivity::class.java) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesFragment.kt new file mode 100644 index 000000000..1a577e548 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesFragment.kt @@ -0,0 +1,119 @@ +/* Copyright 2024 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.preference.notificationpolicies + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.preference.PreferenceFragmentCompat +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.settings.makePreferenceScreen +import com.keylesspalace.tusky.settings.preferenceCategory +import com.keylesspalace.tusky.usecase.NotificationPolicyState +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class NotificationPoliciesFragment : PreferenceFragmentCompat() { + + val viewModel: NotificationPoliciesViewModel by activityViewModels() + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + makePreferenceScreen { + preferenceCategory(title = R.string.notification_policies_filter_out) { category -> + category.isIconSpaceReserved = false + + notificationPolicyPreference { + setTitle(R.string.notification_policies_filter_dont_follow_title) + setSummary(R.string.notification_policies_filter_dont_follow_description) + key = KEY_NOT_FOLLOWING + setOnPreferenceChangeListener { _, newValue -> + viewModel.updatePolicy(forNotFollowing = newValue as String) + true + } + } + + notificationPolicyPreference { + setTitle(R.string.notification_policies_filter_not_following_title) + setSummary(R.string.notification_policies_filter_not_following_description) + key = KEY_NOT_FOLLOWERS + setOnPreferenceChangeListener { _, newValue -> + viewModel.updatePolicy(forNotFollowers = newValue as String) + true + } + } + + notificationPolicyPreference { + setTitle(R.string.unknown_notification_filter_new_accounts_title) + setSummary(R.string.unknown_notification_filter_new_accounts_description) + key = KEY_NEW_ACCOUNTS + setOnPreferenceChangeListener { _, newValue -> + viewModel.updatePolicy(forNewAccounts = newValue as String) + true + } + } + + notificationPolicyPreference { + setTitle(R.string.unknown_notification_filter_unsolicited_private_mentions_title) + setSummary(R.string.unknown_notification_filter_unsolicited_private_mentions_description) + key = KEY_PRIVATE_MENTIONS + setOnPreferenceChangeListener { _, newValue -> + viewModel.updatePolicy(forPrivateMentions = newValue as String) + true + } + } + + notificationPolicyPreference { + setTitle(R.string.unknown_notification_filter_moderated_accounts) + setSummary(R.string.unknown_notification_filter_moderated_accounts_description) + key = KEY_LIMITED_ACCOUNTS + setOnPreferenceChangeListener { _, newValue -> + viewModel.updatePolicy(forLimitedAccounts = newValue as String) + true + } + } + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewLifecycleOwner.lifecycleScope.launch { + viewModel.state.collect { state -> + if (state is NotificationPolicyState.Loaded) { + findPreference(KEY_NOT_FOLLOWING)?.value = state.policy.forNotFollowing.name.lowercase() + findPreference(KEY_NOT_FOLLOWERS)?.value = state.policy.forNotFollowers.name.lowercase() + findPreference(KEY_NEW_ACCOUNTS)?.value = state.policy.forNewAccounts.name.lowercase() + findPreference(KEY_PRIVATE_MENTIONS)?.value = state.policy.forPrivateMentions.name.lowercase() + findPreference(KEY_LIMITED_ACCOUNTS)?.value = state.policy.forLimitedAccounts.name.lowercase() + } + } + } + } + + companion object { + fun newInstance(): NotificationPoliciesFragment { + return NotificationPoliciesFragment() + } + + private const val KEY_NOT_FOLLOWING = "NOT_FOLLOWING" + private const val KEY_NOT_FOLLOWERS = "NOT_FOLLOWERS" + private const val KEY_NEW_ACCOUNTS = "NEW_ACCOUNTS" + private const val KEY_PRIVATE_MENTIONS = "PRIVATE MENTIONS" + private const val KEY_LIMITED_ACCOUNTS = "LIMITED_ACCOUNTS" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesViewModel.kt new file mode 100644 index 000000000..51a5d99e7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesViewModel.kt @@ -0,0 +1,81 @@ +/* Copyright 2024 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.preference.notificationpolicies + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.onFailure +import com.keylesspalace.tusky.usecase.NotificationPolicyState +import com.keylesspalace.tusky.usecase.NotificationPolicyUsecase +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class NotificationPoliciesViewModel @Inject constructor( + private val usecase: NotificationPolicyUsecase +) : ViewModel() { + + val state: StateFlow = usecase.state + + private val _error = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val error: SharedFlow = _error.asSharedFlow() + + init { + loadPolicy() + } + + fun loadPolicy() { + viewModelScope.launch { + usecase.getNotificationPolicy() + } + } + + fun updatePolicy( + forNotFollowing: String? = null, + forNotFollowers: String? = null, + forNewAccounts: String? = null, + forPrivateMentions: String? = null, + forLimitedAccounts: String? = null + ) { + viewModelScope.launch { + usecase.updatePolicy( + forNotFollowing = forNotFollowing, + forNotFollowers = forNotFollowers, + forNewAccounts = forNewAccounts, + forPrivateMentions = forPrivateMentions, + forLimitedAccounts = forLimitedAccounts + ).onFailure { error -> + Log.w(TAG, "failed to update notifications policy", error) + _error.emit(error) + } + } + } + + companion object { + private const val TAG = "NotificationPoliciesViewModel" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPolicyPreference.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPolicyPreference.kt new file mode 100644 index 000000000..fbccafd19 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPolicyPreference.kt @@ -0,0 +1,48 @@ +/* Copyright 2024 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.preference.notificationpolicies + +import android.content.Context +import android.widget.TextView +import androidx.preference.ListPreference +import androidx.preference.PreferenceViewHolder +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.settings.PreferenceParent + +class NotificationPolicyPreference( + context: Context +) : ListPreference(context) { + + init { + widgetLayoutResource = R.layout.preference_notification_policy + setEntries(R.array.notification_policy_options) + setEntryValues(R.array.notification_policy_value) + isIconSpaceReserved = false + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + val switchView: TextView = holder.findViewById(R.id.notification_policy_value) as TextView + switchView.text = entries.getOrNull(findIndexOfValue(value)) + } +} + +inline fun PreferenceParent.notificationPolicyPreference(builder: NotificationPolicyPreference.() -> Unit): NotificationPolicyPreference { + val pref = NotificationPolicyPreference(context) + builder(pref) + addPref(pref) + return pref +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt index 72ebbd203..fa3a43251 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt @@ -19,27 +19,22 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.viewModels +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat.Type.systemBars +import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.report.adapter.ReportPagerAdapter import com.keylesspalace.tusky.databinding.ActivityReportBinding -import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.util.viewBinding -import dagger.android.DispatchingAndroidInjector -import dagger.android.HasAndroidInjector -import javax.inject.Inject +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch -class ReportActivity : BottomSheetActivity(), HasAndroidInjector { +@AndroidEntryPoint +class ReportActivity : BottomSheetActivity() { - @Inject - lateinit var androidInjector: DispatchingAndroidInjector - - @Inject - lateinit var viewModelFactory: ViewModelFactory - - private val viewModel: ReportViewModel by viewModels { viewModelFactory } + private val viewModel: ReportViewModel by viewModels() private val binding by viewBinding(ActivityReportBinding::inflate) @@ -66,6 +61,13 @@ class ReportActivity : BottomSheetActivity(), HasAndroidInjector { setHomeAsUpIndicator(R.drawable.ic_close_24dp) } + ViewCompat.setOnApplyWindowInsetsListener(binding.wizard) { wizard, insets -> + val systemBarInsets = insets.getInsets(systemBars()) + wizard.updatePadding(bottom = systemBarInsets.bottom) + + insets.inset(0, 0, 0, systemBarInsets.bottom) + } + initViewPager() if (savedInstanceState == null) { viewModel.navigateTo(Screen.Statuses) @@ -149,6 +151,4 @@ class ReportActivity : BottomSheetActivity(), HasAndroidInjector { putExtra(STATUS_ID, statusId) } } - - override fun androidInjector() = androidInjector } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt index b2b84d5ae..644ed122c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt @@ -35,7 +35,9 @@ import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.toViewData +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -45,6 +47,8 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +@HiltViewModel +@OptIn(ExperimentalCoroutinesApi::class) class ReportViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub @@ -73,7 +77,10 @@ class ReportViewModel @Inject constructor( val statusesFlow = accountIdFlow.flatMapLatest { accountId -> Pager( initialKey = statusId, - config = PagingConfig(pageSize = 20, initialLoadSize = 20), + config = PagingConfig( + pageSize = 20, + initialLoadSize = 20 + ), pagingSourceFactory = { StatusesPagingSource(accountId, mastodonApi) } ).flow } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/AdapterHandler.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/AdapterHandler.kt index fd150f91a..64e38db33 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/AdapterHandler.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/AdapterHandler.kt @@ -18,9 +18,10 @@ package com.keylesspalace.tusky.components.report.adapter import android.view.View import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.viewdata.StatusViewData interface AdapterHandler : LinkListener { - fun showMedia(v: View?, status: Status?, idx: Int) + fun showMedia(v: View?, status: StatusViewData.Concrete, idx: Int) fun setStatusChecked(status: Status, isChecked: Boolean) fun isStatusChecked(id: String): Boolean } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt index f8db53107..239407523 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt @@ -59,7 +59,7 @@ class StatusViewHolder( private val previewListener = object : StatusViewHelper.MediaPreviewListener { override fun onViewMedia(v: View?, idx: Int) { viewdata()?.let { viewdata -> - adapterHandler.showMedia(v, viewdata.status, idx) + adapterHandler.showMedia(v, viewdata, idx) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportDoneFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportDoneFragment.kt index 2b5ed2628..ba76973c2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportDoneFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportDoneFragment.kt @@ -24,21 +24,15 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.report.Screen import com.keylesspalace.tusky.databinding.FragmentReportDoneBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.util.Loading -import com.keylesspalace.tusky.util.hide -import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding -import javax.inject.Inject +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch -class ReportDoneFragment : Fragment(R.layout.fragment_report_done), Injectable { +@AndroidEntryPoint +class ReportDoneFragment : Fragment(R.layout.fragment_report_done) { - @Inject - lateinit var viewModelFactory: ViewModelFactory - - private val viewModel: ReportViewModel by activityViewModels { viewModelFactory } + private val viewModel: ReportViewModel by activityViewModels() private val binding by viewBinding(FragmentReportDoneBinding::bind) @@ -53,11 +47,11 @@ class ReportDoneFragment : Fragment(R.layout.fragment_report_done), Injectable { viewModel.muteState.collect { if (it == null) return@collect if (it !is Loading) { - binding.buttonMute.show() - binding.progressMute.show() + binding.buttonMute.visibility = View.VISIBLE + binding.progressMute.visibility = View.GONE } else { - binding.buttonMute.hide() - binding.progressMute.hide() + binding.buttonMute.visibility = View.INVISIBLE + binding.progressMute.visibility = View.VISIBLE } binding.buttonMute.setText( @@ -73,11 +67,11 @@ class ReportDoneFragment : Fragment(R.layout.fragment_report_done), Injectable { viewModel.blockState.collect { if (it == null) return@collect if (it !is Loading) { - binding.buttonBlock.show() - binding.progressBlock.show() + binding.buttonBlock.visibility = View.VISIBLE + binding.progressBlock.visibility = View.GONE } else { - binding.buttonBlock.hide() - binding.progressBlock.hide() + binding.buttonBlock.visibility = View.INVISIBLE + binding.progressBlock.visibility = View.VISIBLE } binding.buttonBlock.setText( when (it.data) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt index 215414ff9..87f5c1708 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt @@ -26,24 +26,20 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.report.Screen import com.keylesspalace.tusky.databinding.FragmentReportNoteBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding +import dagger.hilt.android.AndroidEntryPoint import java.io.IOException -import javax.inject.Inject import kotlinx.coroutines.launch -class ReportNoteFragment : Fragment(R.layout.fragment_report_note), Injectable { +@AndroidEntryPoint +class ReportNoteFragment : Fragment(R.layout.fragment_report_note) { - @Inject - lateinit var viewModelFactory: ViewModelFactory - - private val viewModel: ReportViewModel by activityViewModels { viewModelFactory } + private val viewModel: ReportViewModel by activityViewModels() private val binding by viewBinding(FragmentReportNoteBinding::bind) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt index 7652b8419..66a1c29f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.components.report.fragments +import android.content.SharedPreferences import android.os.Bundle import android.view.Menu import android.view.MenuInflater @@ -28,7 +29,6 @@ import androidx.fragment.app.activityViewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState -import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator @@ -45,8 +45,6 @@ import com.keylesspalace.tusky.components.report.adapter.AdapterHandler import com.keylesspalace.tusky.components.report.adapter.StatusesAdapter import com.keylesspalace.tusky.databinding.FragmentReportStatusesBinding import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.settings.PrefKeys @@ -55,57 +53,58 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.viewdata.AttachmentViewData +import com.keylesspalace.tusky.viewdata.StatusViewData 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.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +@AndroidEntryPoint class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), - Injectable, OnRefreshListener, MenuProvider, AdapterHandler { - @Inject - lateinit var viewModelFactory: ViewModelFactory - @Inject lateinit var accountManager: AccountManager - private val viewModel: ReportViewModel by activityViewModels { viewModelFactory } + @Inject + lateinit var preferences: SharedPreferences + + private val viewModel: ReportViewModel by activityViewModels() private val binding by viewBinding(FragmentReportStatusesBinding::bind) - private lateinit var adapter: StatusesAdapter + private var adapter: StatusesAdapter? = null private var snackbarErrorRetry: Snackbar? = null - override fun showMedia(v: View?, status: Status?, idx: Int) { - status?.actionableStatus?.let { actionable -> - when (actionable.attachments[idx].type) { - Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { - val attachments = AttachmentViewData.list(actionable) - val intent = ViewMediaActivity.newIntent(context, attachments, idx) - if (v != null) { - val url = actionable.attachments[idx].url - ViewCompat.setTransitionName(v, url) - val options = ActivityOptionsCompat.makeSceneTransitionAnimation( - requireActivity(), - v, - url - ) - startActivity(intent, options.toBundle()) - } else { - startActivity(intent) - } - } - Attachment.Type.UNKNOWN -> { + override fun showMedia(v: View?, status: StatusViewData.Concrete, idx: Int) { + when (status.attachments[idx].type) { + Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { + val attachments = AttachmentViewData.list(status) + val intent = ViewMediaActivity.newIntent(context, attachments, idx) + if (v != null) { + val url = status.attachments[idx].url + ViewCompat.setTransitionName(v, url) + val options = ActivityOptionsCompat.makeSceneTransitionAnimation( + requireActivity(), + v, + url + ) + startActivity(intent, options.toBundle()) + } else { + startActivity(intent) } } + + Attachment.Type.UNKNOWN -> { + } } } @@ -113,7 +112,14 @@ class ReportStatusesFragment : requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) handleClicks() initStatusesView() - setupSwipeRefreshLayout() + binding.swipeRefreshLayout.setOnRefreshListener(this) + } + + override fun onDestroyView() { + // Clear the adapter to prevent leaking the View + adapter = null + snackbarErrorRetry = null + super.onDestroyView() } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { @@ -133,23 +139,18 @@ class ReportStatusesFragment : onRefresh() true } + else -> false } } override fun onRefresh() { snackbarErrorRetry?.dismiss() - adapter.refresh() - } - - private fun setupSwipeRefreshLayout() { - binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) - - binding.swipeRefreshLayout.setOnRefreshListener(this) + snackbarErrorRetry = null + adapter?.refresh() } private fun initStatusesView() { - val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) val statusDisplayOptions = StatusDisplayOptions( animateAvatars = false, mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, @@ -166,7 +167,8 @@ class ReportStatusesFragment : openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) - adapter = StatusesAdapter(statusDisplayOptions, viewModel.statusViewState, this) + val adapter = StatusesAdapter(statusDisplayOptions, viewModel.statusViewState, this) + this.adapter = adapter binding.recyclerView.addItemDecoration( DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL) @@ -175,7 +177,7 @@ class ReportStatusesFragment : binding.recyclerView.adapter = adapter (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false - lifecycleScope.launch { + viewLifecycleOwner.lifecycleScope.launch { viewModel.statusesFlow.collectLatest { pagingData -> adapter.submitData(pagingData) } @@ -186,7 +188,7 @@ class ReportStatusesFragment : loadState.append is LoadState.Error || loadState.prepend is LoadState.Error ) { - showError() + showError(adapter) } binding.progressBarBottom.visible(loadState.append == LoadState.Loading) @@ -201,13 +203,15 @@ class ReportStatusesFragment : } } - private fun showError() { + private fun showError(adapter: StatusesAdapter) { if (snackbarErrorRetry?.isShown != true) { - snackbarErrorRetry = Snackbar.make(binding.swipeRefreshLayout, R.string.failed_fetch_posts, Snackbar.LENGTH_INDEFINITE) - snackbarErrorRetry?.setAction(R.string.action_retry) { - adapter.retry() - } - snackbarErrorRetry?.show() + snackbarErrorRetry = + Snackbar.make(binding.swipeRefreshLayout, R.string.failed_fetch_posts, Snackbar.LENGTH_INDEFINITE) + .setAction(R.string.action_retry) { + adapter.retry() + }.also { + it.show() + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt index 2d3adf397..d613d78d4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt @@ -22,22 +22,21 @@ import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.activity.viewModels -import androidx.appcompat.app.AlertDialog import androidx.core.view.MenuProvider import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.color.MaterialColors +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.StatusScheduledEvent import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.databinding.ActivityScheduledStatusBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.ScheduledStatus +import com.keylesspalace.tusky.util.ensureBottomPadding import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding @@ -45,23 +44,21 @@ 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.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +@AndroidEntryPoint class ScheduledStatusActivity : BaseActivity(), ScheduledStatusActionListener, - MenuProvider, - Injectable { - - @Inject - lateinit var viewModelFactory: ViewModelFactory + MenuProvider { @Inject lateinit var eventHub: EventHub - private val viewModel: ScheduledStatusViewModel by viewModels { viewModelFactory } + private val viewModel: ScheduledStatusViewModel by viewModels() private val binding by viewBinding(ActivityScheduledStatusBinding::inflate) @@ -80,8 +77,9 @@ class ScheduledStatusActivity : setDisplayShowHomeEnabled(true) } + binding.scheduledTootList.ensureBottomPadding() + binding.swipeRefreshLayout.setOnRefreshListener(this::refreshStatuses) - binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) binding.scheduledTootList.setHasFixedSize(true) binding.scheduledTootList.layoutManager = LinearLayoutManager(this) @@ -173,7 +171,7 @@ class ScheduledStatusActivity : } override fun delete(item: ScheduledStatus) { - AlertDialog.Builder(this) + MaterialAlertDialogBuilder(this) .setMessage(R.string.delete_scheduled_post_warning) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(android.R.string.ok) { _, _ -> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusViewModel.kt index 821364b9e..2fa670f67 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusViewModel.kt @@ -22,21 +22,24 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import at.connyduck.calladapter.networkresult.fold -import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.network.MastodonApi +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.launch +@HiltViewModel class ScheduledStatusViewModel @Inject constructor( - val mastodonApi: MastodonApi, - val eventHub: EventHub + val mastodonApi: MastodonApi ) : ViewModel() { private val pagingSourceFactory = ScheduledStatusPagingSourceFactory(mastodonApi) val data = Pager( - config = PagingConfig(pageSize = 20, initialLoadSize = 20), + config = PagingConfig( + pageSize = 20, + initialLoadSize = 20 + ), pagingSourceFactory = pagingSourceFactory ).flow .cachedIn(viewModelScope) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt index 7cdff23fa..21c0b00ec 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt @@ -22,36 +22,29 @@ import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem +import android.view.MotionEvent import androidx.activity.viewModels import androidx.appcompat.widget.SearchView import androidx.core.view.MenuProvider -import androidx.preference.PreferenceManager +import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.search.adapter.SearchPagerAdapter import com.keylesspalace.tusky.databinding.ActivitySearchBinding -import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.reduceSwipeSensitivity -import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding -import dagger.android.DispatchingAndroidInjector -import dagger.android.HasAndroidInjector -import javax.inject.Inject +import dagger.hilt.android.AndroidEntryPoint -class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider, SearchView.OnQueryTextListener { - @Inject - lateinit var androidInjector: DispatchingAndroidInjector +@AndroidEntryPoint +class SearchActivity : BottomSheetActivity(), MenuProvider, SearchView.OnQueryTextListener { - @Inject - lateinit var viewModelFactory: ViewModelFactory - - private val viewModel: SearchViewModel by viewModels { viewModelFactory } + private val viewModel: SearchViewModel by viewModels() private val binding by viewBinding(ActivitySearchBinding::inflate) - private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) } + private lateinit var searchView: SearchView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -89,8 +82,9 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider, menuInflater.inflate(R.menu.search_toolbar, menu) val searchViewMenuItem = menu.findItem(R.id.action_search) searchViewMenuItem.expandActionView() - val searchView = searchViewMenuItem.actionView as SearchView - setupSearchView(searchView) + searchView = searchViewMenuItem.actionView as SearchView + setupSearchView() + setupClearFocusOnClickListeners() } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { @@ -110,10 +104,35 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider, if (Intent.ACTION_SEARCH == intent.action) { viewModel.currentQuery = intent.getStringExtra(SearchManager.QUERY).orEmpty() viewModel.search(viewModel.currentQuery) + searchView.clearFocus() } } - private fun setupSearchView(searchView: SearchView) { + private fun setupClearFocusOnClickListeners() { + binding.overlayPagesClickView.setOnTouchListener { view, event -> + if (event.action == MotionEvent.ACTION_DOWN) { + searchView.clearFocus() + view.performClick() + } + false + } + binding.toolbar.setOnClickListener { + searchView.clearFocus() + } + binding.tabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabSelected(p0: TabLayout.Tab?) { + searchView.clearFocus() + } + + override fun onTabUnselected(p0: TabLayout.Tab?) {} + + override fun onTabReselected(p0: TabLayout.Tab?) { + searchView.clearFocus() + } + }) + } + + private fun setupSearchView() { searchView.setIconifiedByDefault(false) searchView.setSearchableInfo( ( @@ -154,7 +173,7 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider, searchView.setOnQueryTextListener(this) searchView.setQuery(viewModel.currentSearchFieldContent ?: "", false) - searchView.requestFocus() + if (viewModel.currentSearchFieldContent == "") searchView.requestFocus() } override fun onQueryTextSubmit(query: String?): Boolean { @@ -167,8 +186,6 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider, return false } - override fun androidInjector() = androidInjector - companion object { const val TAG = "SearchActivity" fun getIntent(context: Context) = Intent(context, SearchActivity::class.java) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt index ba075449e..09bda62a1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt @@ -27,8 +27,8 @@ import at.connyduck.calladapter.networkresult.map import at.connyduck.calladapter.networkresult.onFailure import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory -import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi @@ -36,11 +36,13 @@ import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.launch +@HiltViewModel class SearchViewModel @Inject constructor( mastodonApi: MastodonApi, private val timelineCases: TimelineCases, @@ -58,9 +60,9 @@ class SearchViewModel @Inject constructor( val activeAccount: AccountEntity? get() = accountManager.activeAccount - val mediaPreviewEnabled = activeAccount?.mediaPreviewEnabled ?: false - val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false - val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false + val mediaPreviewEnabled = activeAccount?.mediaPreviewEnabled == true + val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia == true + val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler == true private val loadedStatuses: MutableList = mutableListOf() @@ -86,19 +88,28 @@ class SearchViewModel @Inject constructor( } val statusesFlow = Pager( - config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE), + config = PagingConfig( + pageSize = DEFAULT_LOAD_SIZE, + initialLoadSize = DEFAULT_LOAD_SIZE + ), pagingSourceFactory = statusesPagingSourceFactory ).flow .cachedIn(viewModelScope) val accountsFlow = Pager( - config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE), + config = PagingConfig( + pageSize = DEFAULT_LOAD_SIZE, + initialLoadSize = DEFAULT_LOAD_SIZE + ), pagingSourceFactory = accountsPagingSourceFactory ).flow .cachedIn(viewModelScope) val hashtagsFlow = Pager( - config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE), + config = PagingConfig( + pageSize = DEFAULT_LOAD_SIZE, + initialLoadSize = DEFAULT_LOAD_SIZE + ), pagingSourceFactory = hashtagsPagingSourceFactory ).flow .cachedIn(viewModelScope) @@ -120,13 +131,17 @@ class SearchViewModel @Inject constructor( } } + fun clearStatusCache() { + loadedStatuses.clear() + } + fun expandedChange(statusViewData: StatusViewData.Concrete, expanded: Boolean) { updateStatusViewData(statusViewData.copy(isExpanded = expanded)) } - fun reblog(statusViewData: StatusViewData.Concrete, reblog: Boolean) { + fun reblog(statusViewData: StatusViewData.Concrete, reblog: Boolean, visibility: Status.Visibility = Status.Visibility.PUBLIC) { viewModelScope.launch { - timelineCases.reblog(statusViewData.id, reblog).fold({ + timelineCases.reblog(statusViewData.id, reblog, visibility).fold({ updateStatus( statusViewData.status.copy( reblogged = reblog, @@ -147,7 +162,7 @@ class SearchViewModel @Inject constructor( updateStatusViewData(statusViewData.copy(isCollapsed = collapsed)) } - fun voteInPoll(statusViewData: StatusViewData.Concrete, choices: MutableList) { + fun voteInPoll(statusViewData: StatusViewData.Concrete, choices: List) { val votedPoll = statusViewData.status.actionableStatus.poll!!.votedCopy(choices) updateStatus(statusViewData.status.copy(poll = votedPoll)) viewModelScope.launch { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt index 052ccc9b9..d31a48683 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt @@ -19,6 +19,7 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil +import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemHashtagBinding import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.interfaces.LinkListener @@ -37,7 +38,7 @@ class SearchHashtagsAdapter(private val linkListener: LinkListener) : override fun onBindViewHolder(holder: BindingHolder, position: Int) { getItem(position)?.let { (name) -> - holder.binding.root.text = String.format("#%s", name) + holder.binding.root.text = holder.binding.root.context.getString(R.string.hashtag_format, name) holder.binding.root.setOnClickListener { linkListener.onViewTag(name) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt index 1d3cabe21..c3b99a809 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt @@ -20,6 +20,7 @@ import android.view.ViewGroup import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.adapter.StatusViewHolder import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions @@ -37,23 +38,44 @@ class SearchStatusesAdapter( } override fun onBindViewHolder(holder: StatusViewHolder, position: Int) { + onBindViewHolder(holder, position, emptyList()) + } + + override fun onBindViewHolder(holder: StatusViewHolder, position: Int, payloads: List) { getItem(position)?.let { item -> - holder.setupWithStatus(item, statusListener, statusDisplayOptions) + holder.setupWithStatus(item, statusListener, statusDisplayOptions, payloads, true) } } companion object { val STATUS_COMPARATOR = object : DiffUtil.ItemCallback() { - override fun areContentsTheSame( - oldItem: StatusViewData.Concrete, - newItem: StatusViewData.Concrete - ): Boolean = oldItem == newItem - override fun areItemsTheSame( oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete - ): Boolean = oldItem.id == newItem.id + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: StatusViewData.Concrete, + newItem: StatusViewData.Concrete + ): Boolean { + return false // Items are different always. It allows to refresh timestamp on every view holder update + } + + override fun getChangePayload( + oldItem: StatusViewData.Concrete, + newItem: StatusViewData.Concrete + ): Any? { + return if (oldItem == newItem) { + // If items are equal - update timestamp only + StatusBaseViewHolder.Key.KEY_CREATED + } else { + // If items are different - update the whole view holder + null + } + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt index 8e1c8100f..a413feefc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt @@ -15,18 +15,25 @@ package com.keylesspalace.tusky.components.search.fragments +import android.content.SharedPreferences import android.os.Bundle import android.view.View import androidx.paging.PagingData import androidx.paging.PagingDataAdapter -import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.settings.PrefKeys +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import kotlinx.coroutines.flow.Flow +@AndroidEntryPoint class SearchAccountsFragment : SearchFragment() { + + @Inject + lateinit var preferences: SharedPreferences + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.searchRecyclerView.addItemDecoration( @@ -38,10 +45,6 @@ class SearchAccountsFragment : SearchFragment() { } override fun createAdapter(): PagingDataAdapter { - val preferences = PreferenceManager.getDefaultSharedPreferences( - binding.searchRecyclerView.context - ) - return SearchAccountsAdapter( this, preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt index cb8ea8cb2..7b6b3f3fd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt @@ -24,10 +24,9 @@ import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.search.SearchViewModel import com.keylesspalace.tusky.databinding.FragmentSearchBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.ensureBottomPadding import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible @@ -43,17 +42,13 @@ import kotlinx.coroutines.launch abstract class SearchFragment : Fragment(R.layout.fragment_search), LinkListener, - Injectable, SwipeRefreshLayout.OnRefreshListener, MenuProvider { - @Inject - lateinit var viewModelFactory: ViewModelFactory - @Inject lateinit var mastodonApi: MastodonApi - protected val viewModel: SearchViewModel by activityViewModels { viewModelFactory } + protected val viewModel: SearchViewModel by activityViewModels() protected val binding by viewBinding(FragmentSearchBinding::bind) @@ -62,23 +57,26 @@ abstract class SearchFragment : abstract fun createAdapter(): PagingDataAdapter abstract val data: Flow> - protected lateinit var adapter: PagingDataAdapter + protected var adapter: PagingDataAdapter? = null private var currentQuery: String = "" override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - initAdapter() - setupSwipeRefreshLayout() - requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - subscribeObservables() - } - - private fun setupSwipeRefreshLayout() { + val adapter = initAdapter() binding.swipeRefreshLayout.setOnRefreshListener(this) - binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) + binding.searchRecyclerView.ensureBottomPadding() + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + subscribeObservables(adapter) } - private fun subscribeObservables() { + override fun onDestroyView() { + // Clear the adapter to prevent leaking the View + adapter = null + snackbarErrorRetry = null + super.onDestroyView() + } + + private fun subscribeObservables(adapter: PagingDataAdapter) { viewLifecycleOwner.lifecycleScope.launch { data.collectLatest { pagingData -> adapter.submitData(pagingData) @@ -88,7 +86,7 @@ abstract class SearchFragment : adapter.addLoadStateListener { loadState -> if (loadState.refresh is LoadState.Error) { - showError() + showError(adapter) } val isNewSearch = currentQuery != viewModel.currentQuery @@ -130,26 +128,31 @@ abstract class SearchFragment : onRefresh() true } + else -> false } } - private fun initAdapter() { + private fun initAdapter(): PagingDataAdapter { binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context) - adapter = createAdapter() + val adapter = createAdapter() + this.adapter = adapter binding.searchRecyclerView.adapter = adapter binding.searchRecyclerView.setHasFixedSize(true) (binding.searchRecyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + return adapter } - private fun showError() { + private fun showError(adapter: PagingDataAdapter) { if (snackbarErrorRetry?.isShown != true) { - snackbarErrorRetry = Snackbar.make(binding.root, R.string.failed_search, Snackbar.LENGTH_INDEFINITE) - snackbarErrorRetry?.setAction(R.string.action_retry) { - snackbarErrorRetry = null - adapter.retry() - } - snackbarErrorRetry?.show() + snackbarErrorRetry = + Snackbar.make(binding.root, R.string.failed_search, Snackbar.LENGTH_INDEFINITE) + .setAction(R.string.action_retry) { + snackbarErrorRetry = null + adapter.retry() + }.also { + it.show() + } } } @@ -173,6 +176,8 @@ abstract class SearchFragment : get() = (activity as? BottomSheetActivity) override fun onRefresh() { - adapter.refresh() + snackbarErrorRetry?.dismiss() + snackbarErrorRetry = null + adapter?.refresh() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt index 8c4f41fb0..7bd3c9d35 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt @@ -22,8 +22,10 @@ import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DividerItemDecoration import com.keylesspalace.tusky.components.search.adapter.SearchHashtagsAdapter import com.keylesspalace.tusky.entity.HashTag +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.Flow +@AndroidEntryPoint class SearchHashtagsFragment : SearchFragment() { override val data: Flow> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index abbfea9d0..525f4e9dc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -17,39 +17,37 @@ package com.keylesspalace.tusky.components.search.fragments import android.Manifest import android.app.DownloadManager -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import android.content.Intent -import android.content.pm.PackageManager +import android.content.SharedPreferences import android.net.Uri import android.os.Build +import android.os.Bundle import android.os.Environment import android.util.Log import android.view.View import android.widget.Toast -import androidx.appcompat.app.AlertDialog +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityOptionsCompat +import androidx.core.content.getSystemService import androidx.core.view.ViewCompat import androidx.lifecycle.lifecycleScope import androidx.paging.PagingData import androidx.paging.PagingDataAdapter -import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.onFailure +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.search.adapter.SearchStatusesAdapter -import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status.Mention @@ -59,30 +57,67 @@ import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.CardViewMode import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.copyToClipboard import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation +import com.keylesspalace.tusky.util.updateRelativeTimePeriodically import com.keylesspalace.tusky.view.showMuteAccountDialog import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData +import dagger.hilt.android.AndroidEntryPoint import java.util.Locale import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch +@AndroidEntryPoint class SearchStatusesFragment : SearchFragment(), StatusActionListener { @Inject lateinit var accountManager: AccountManager + @Inject + lateinit var preferences: SharedPreferences + override val data: Flow> get() = viewModel.statusesFlow - private val searchAdapter - get() = super.adapter as SearchStatusesAdapter + private var pendingMediaDownloads: List? = null + + private val downloadAllMediaPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) { + pendingMediaDownloads?.let { downloadAllMedia(it) } + } else { + Toast.makeText( + context, + R.string.error_media_download_permission, + Toast.LENGTH_SHORT + ).show() + } + pendingMediaDownloads = null + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + pendingMediaDownloads = savedInstanceState?.getStringArrayList(PENDING_MEDIA_DOWNLOADS_STATE_KEY) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + adapter?.let { + updateRelativeTimePeriodically(preferences, it) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + pendingMediaDownloads?.let { + outState.putStringArrayList(PENDING_MEDIA_DOWNLOADS_STATE_KEY, ArrayList(it)) + } + } override fun createAdapter(): PagingDataAdapter { - val preferences = PreferenceManager.getDefaultSharedPreferences( - binding.searchRecyclerView.context - ) val statusDisplayOptions = StatusDisplayOptions( animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), mediaPreviewEnabled = viewModel.mediaPreviewEnabled, @@ -98,6 +133,7 @@ class SearchStatusesFragment : SearchFragment(), Status showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) + val adapter = SearchStatusesAdapter(statusDisplayOptions, this) binding.searchRecyclerView.setAccessibilityDelegateCompat( ListStatusAccessibilityDelegate(binding.searchRecyclerView, this) { pos -> @@ -117,51 +153,56 @@ class SearchStatusesFragment : SearchFragment(), Status ) binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context) - return SearchStatusesAdapter(statusDisplayOptions, this) + return adapter + } + + override fun onRefresh() { + viewModel.clearStatusCache() + super.onRefresh() } override fun onContentHiddenChange(isShowing: Boolean, position: Int) { - searchAdapter.peek(position)?.let { + adapter?.peek(position)?.let { viewModel.contentHiddenChange(it, isShowing) } } override fun onReply(position: Int) { - searchAdapter.peek(position)?.let { status -> + adapter?.peek(position)?.let { status -> reply(status) } } override fun onFavourite(favourite: Boolean, position: Int) { - searchAdapter.peek(position)?.let { status -> + adapter?.peek(position)?.let { status -> viewModel.favorite(status, favourite) } } override fun onBookmark(bookmark: Boolean, position: Int) { - searchAdapter.peek(position)?.let { status -> + adapter?.peek(position)?.let { status -> viewModel.bookmark(status, bookmark) } } override fun onMore(view: View, position: Int) { - searchAdapter.peek(position)?.let { + adapter?.peek(position)?.let { more(it, view, position) } } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - searchAdapter.peek(position)?.status?.actionableStatus?.let { actionable -> - when (actionable.attachments[attachmentIndex].type) { + adapter?.peek(position)?.let { status -> + when (status.attachments[attachmentIndex].type) { Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { - val attachments = AttachmentViewData.list(actionable) + val attachments = AttachmentViewData.list(status) val intent = ViewMediaActivity.newIntent( context, attachments, attachmentIndex ) if (view != null) { - val url = actionable.attachments[attachmentIndex].url + val url = status.attachments[attachmentIndex].url ViewCompat.setTransitionName(view, url) val options = ActivityOptionsCompat.makeSceneTransitionAnimation( requireActivity(), @@ -175,27 +216,27 @@ class SearchStatusesFragment : SearchFragment(), Status } Attachment.Type.UNKNOWN -> { - context?.openLink(actionable.attachments[attachmentIndex].url) + context?.openLink(status.attachments[attachmentIndex].unknownUrl) } } } } override fun onViewThread(position: Int) { - searchAdapter.peek(position)?.status?.let { status -> + adapter?.peek(position)?.status?.let { status -> val actionableStatus = status.actionableStatus bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url) } } override fun onOpenReblog(position: Int) { - searchAdapter.peek(position)?.status?.let { status -> + adapter?.peek(position)?.status?.let { status -> bottomSheetActivity?.viewAccount(status.account.id) } } override fun onExpandedChange(expanded: Boolean, position: Int) { - searchAdapter.peek(position)?.let { + adapter?.peek(position)?.let { viewModel.expandedChange(it, expanded) } } @@ -205,13 +246,13 @@ class SearchStatusesFragment : SearchFragment(), Status } override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - searchAdapter.peek(position)?.let { + adapter?.peek(position)?.let { viewModel.collapsedChange(it, isCollapsed) } } - override fun onVoteInPoll(position: Int, choices: MutableList) { - searchAdapter.peek(position)?.let { + override fun onVoteInPoll(position: Int, choices: List) { + adapter?.peek(position)?.let { viewModel.voteInPoll(it, choices) } } @@ -219,27 +260,23 @@ class SearchStatusesFragment : SearchFragment(), Status override fun clearWarningAction(position: Int) {} private fun removeItem(position: Int) { - searchAdapter.peek(position)?.let { + adapter?.peek(position)?.let { viewModel.removeItem(it) } } - override fun onReblog(reblog: Boolean, position: Int) { - searchAdapter.peek(position)?.let { status -> - viewModel.reblog(status, reblog) + override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) { + adapter?.peek(position)?.let { status -> + viewModel.reblog(status, reblog, visibility) } } override fun onUntranslate(position: Int) { - searchAdapter.peek(position)?.let { + adapter?.peek(position)?.let { viewModel.untranslate(it) } } - companion object { - fun newInstance() = SearchStatusesFragment() - } - private fun reply(status: StatusViewData.Concrete) { val actionableStatus = status.actionable val mentionedUsernames = actionableStatus.mentions.map { it.username } @@ -373,10 +410,7 @@ class SearchStatusesFragment : SearchFragment(), Status } R.id.status_copy_link -> { - val clipboard = requireActivity().getSystemService( - Context.CLIPBOARD_SERVICE - ) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText(null, statusUrl)) + statusUrl?.let { requireActivity().copyToClipboard(it, getString(R.string.url_copied)) } return@setOnMenuItemClickListener true } @@ -391,7 +425,7 @@ class SearchStatusesFragment : SearchFragment(), Status } R.id.status_mute_conversation -> { - searchAdapter.peek(position)?.let { foundStatus -> + adapter?.peek(position)?.let { foundStatus -> viewModel.muteConversation(foundStatus, !status.muted) } return@setOnMenuItemClickListener true @@ -433,7 +467,7 @@ class SearchStatusesFragment : SearchFragment(), Status } R.id.status_edit -> { - editStatus(id, position, status) + editStatus(id, status) return@setOnMenuItemClickListener true } @@ -446,7 +480,7 @@ class SearchStatusesFragment : SearchFragment(), Status if (statusViewData.translation != null) { viewModel.untranslate(statusViewData) } else { - lifecycleScope.launch { + viewLifecycleOwner.lifecycleScope.launch { viewModel.translate(statusViewData) .onFailure { Snackbar.make( @@ -465,7 +499,7 @@ class SearchStatusesFragment : SearchFragment(), Status } private fun onBlock(accountId: String, accountUsername: String) { - AlertDialog.Builder(requireContext()) + MaterialAlertDialogBuilder(requireContext()) .setMessage(getString(R.string.dialog_block_warning, accountUsername)) .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.blockAccount(accountId) } .setNegativeButton(android.R.string.cancel, null) @@ -499,37 +533,31 @@ class SearchStatusesFragment : SearchFragment(), Status ) } - private fun downloadAllMedia(status: Status) { + private fun downloadAllMedia(mediaUrls: List) { Toast.makeText(context, R.string.downloading_media, Toast.LENGTH_SHORT).show() - for ((_, url) in status.attachments) { - val uri = Uri.parse(url) - val filename = uri.lastPathSegment + val downloadManager: DownloadManager = requireContext().getSystemService()!! - val downloadManager = requireActivity().getSystemService( - Context.DOWNLOAD_SERVICE - ) as DownloadManager + for (url in mediaUrls) { + val uri = Uri.parse(url) val request = DownloadManager.Request(uri) - request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename) + request.setDestinationInExternalPublicDir( + Environment.DIRECTORY_DOWNLOADS, + uri.lastPathSegment + ) downloadManager.enqueue(request) } } private fun requestDownloadAllMedia(status: Status) { + if (status.attachments.isEmpty()) { + return + } + val mediaUrls = status.attachments.map { it.url } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - val permissions = arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE) - (activity as BaseActivity).requestPermissions(permissions) { _, grantResults -> - if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - downloadAllMedia(status) - } else { - Toast.makeText( - context, - R.string.error_media_download_permission, - Toast.LENGTH_SHORT - ).show() - } - } + pendingMediaDownloads = mediaUrls + downloadAllMediaPermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) } else { - downloadAllMedia(status) + downloadAllMedia(mediaUrls) } } @@ -541,7 +569,7 @@ class SearchStatusesFragment : SearchFragment(), Status private fun showConfirmDeleteDialog(id: String, position: Int) { context?.let { - AlertDialog.Builder(it) + MaterialAlertDialogBuilder(it) .setMessage(R.string.dialog_delete_post_warning) .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.deleteStatusAsync(id) @@ -553,11 +581,11 @@ class SearchStatusesFragment : SearchFragment(), Status } private fun showConfirmEditDialog(id: String, position: Int, status: Status) { - activity?.let { - AlertDialog.Builder(it) + context?.let { context -> + MaterialAlertDialogBuilder(context) .setMessage(R.string.dialog_redraft_post_warning) .setPositiveButton(android.R.string.ok) { _, _ -> - lifecycleScope.launch { + viewLifecycleOwner.lifecycleScope.launch { viewModel.deleteStatusAsync(id).await().fold( { deletedStatus -> removeItem(position) @@ -569,7 +597,7 @@ class SearchStatusesFragment : SearchFragment(), Status } val intent = ComposeActivity.startIntent( - requireContext(), + context, ComposeOptions( content = redraftStatus.text.orEmpty(), inReplyToId = redraftStatus.inReplyToId, @@ -600,8 +628,8 @@ class SearchStatusesFragment : SearchFragment(), Status } } - private fun editStatus(id: String, position: Int, status: Status) { - lifecycleScope.launch { + private fun editStatus(id: String, status: Status) { + viewLifecycleOwner.lifecycleScope.launch { mastodonApi.statusSource(id).fold( { source -> val composeOptions = ComposeOptions( @@ -628,4 +656,10 @@ class SearchStatusesFragment : SearchFragment(), Status ) } } + + companion object { + private const val PENDING_MEDIA_DOWNLOADS_STATE_KEY = "pending_media_downloads" + + fun newInstance() = SearchStatusesFragment() + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationChannelData.kt b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationChannelData.kt new file mode 100644 index 000000000..7d07eb718 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationChannelData.kt @@ -0,0 +1,86 @@ +package com.keylesspalace.tusky.components.systemnotifications + +import androidx.annotation.Keep +import androidx.annotation.StringRes +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.db.entity.AccountEntity +import com.keylesspalace.tusky.entity.Notification + +@Keep +enum class NotificationChannelData( + val notificationTypes: List, + @StringRes val title: Int, + @StringRes val description: Int, +) { + MENTION( + listOf(Notification.Type.Mention), + R.string.notification_mention_name, + R.string.notification_mention_descriptions, + ), + + REBLOG( + listOf(Notification.Type.Reblog), + R.string.notification_boost_name, + R.string.notification_boost_description + ), + + FAVOURITE( + listOf(Notification.Type.Favourite), + R.string.notification_favourite_name, + R.string.notification_favourite_description + ), + + FOLLOW( + listOf(Notification.Type.Follow), + R.string.notification_follow_name, + R.string.notification_follow_description + ), + + FOLLOW_REQUEST( + listOf(Notification.Type.FollowRequest), + R.string.notification_follow_request_name, + R.string.notification_follow_request_description + ), + + POLL( + listOf(Notification.Type.Poll), + R.string.notification_poll_name, + R.string.notification_poll_description + ), + + SUBSCRIPTIONS( + listOf(Notification.Type.Status), + R.string.notification_subscription_name, + R.string.notification_subscription_description + ), + + UPDATES( + listOf(Notification.Type.Update), + R.string.notification_update_name, + R.string.notification_update_description + ), + + ADMIN( + listOf(Notification.Type.SignUp, Notification.Type.Report), + R.string.notification_channel_admin, + R.string.notification_channel_admin_description + ), + + OTHER( + listOf(Notification.Type.SeveredRelationship, Notification.Type.ModerationWarning), + R.string.notification_channel_other, + R.string.notification_channel_other_description + ); + + fun getChannelId(account: AccountEntity): String { + return getChannelId(account.identifier) + } + + fun getChannelId(accountIdentifier: String): String { + return "CHANNEL_${name}$accountIdentifier" + } +} + +fun Set.toTypes(): Set { + return flatMap { channelData -> channelData.notificationTypes }.toSet() +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt similarity index 52% rename from app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt rename to app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt index 91e97a41d..d65d4381b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt @@ -1,23 +1,17 @@ -package com.keylesspalace.tusky.components.notifications +package com.keylesspalace.tusky.components.systemnotifications -import android.app.NotificationManager -import android.content.Context import android.util.Log import androidx.annotation.WorkerThread import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.NewNotificationsEvent -import com.keylesspalace.tusky.components.notifications.NotificationHelper.filterNotification -import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.Marker import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.HttpHeaderLink import com.keylesspalace.tusky.util.isLessThan import javax.inject.Inject -import kotlin.math.min -import kotlin.time.Duration.Companion.milliseconds -import kotlinx.coroutines.delay /** Models next/prev links from the "Links" header in an API response */ data class Links(val next: String?, val prev: String?) { @@ -48,93 +42,26 @@ data class Links(val next: String?, val prev: String?) { class NotificationFetcher @Inject constructor( private val mastodonApi: MastodonApi, private val accountManager: AccountManager, - private val context: Context, - private val eventHub: EventHub + private val eventHub: EventHub, + private val notificationService: NotificationService, ) { - suspend fun fetchAndShow() { - for (account in accountManager.getAllAccountsOrderedByActive()) { + suspend fun fetchAndShow(accountId: Long?) { + for (account in accountManager.accounts) { + if (accountId != null && account.id != accountId) { + continue + } + if (account.notificationsEnabled) { try { - val notificationManager = context.getSystemService( - Context.NOTIFICATION_SERVICE - ) as NotificationManager - - // Create sorted list of new notifications val notifications = fetchNewNotifications(account) - .filter { filterNotification(notificationManager, account, it) } + .filter { notificationService.filterNotification(account, it.type) } .sortedWith( compareBy({ it.id.length }, { it.id }) ) // oldest notifications first - .toMutableList() - // TODO do this before filter above? But one could argue that (for example) a tab badge is also a notification - // (and should therefore adhere to the notification config). eventHub.dispatch(NewNotificationsEvent(account.accountId, notifications)) - // 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) - } - } - - val notificationsByType = notifications.groupBy { it.type } - - // 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. - - notificationsByType.forEach { notificationsGroup -> - notificationsGroup.value.forEach { notification -> - val androidNotification = NotificationHelper.make( - context, - notificationManager, - notification, - account, - notificationsGroup.value.size == 1 - ) - notificationManager.notify( - notification.id, - account.id.toInt(), - androidNotification - ) - - // Android will rate limit / drop notifications if they're posted too - // quickly. There is no indication to the user that this happened. - // See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664 - delay(1000.milliseconds) - } - } - - NotificationHelper.updateSummaryNotifications( - context, - notificationManager, - account - ) - - accountManager.saveAccount(account) + notificationService.show(account, notifications) } catch (e: Exception) { Log.e(TAG, "Error while fetching notifications", e) } @@ -161,14 +88,14 @@ class NotificationFetcher @Inject constructor( * than the marker. */ private suspend fun fetchNewNotifications(account: AccountEntity): List { - val authHeader = String.format("Bearer %s", account.accessToken) + val authHeader = "Bearer ${account.accessToken}" // 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}") + 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( @@ -186,10 +113,10 @@ class NotificationFetcher @Inject constructor( Log.d(TAG, " localMarkerId: $localMarkerId") Log.d(TAG, " readingPosition: $readingPosition") - Log.d(TAG, "getting Notifications for ${account.fullName}, min_id: $minId") + Log.d(TAG, "Getting Notifications for ${account.fullName}, min_id: $minId.") // Fetch all outstanding notifications - val notifications = buildList { + val notifications: List = buildList { while (minId != null) { val response = mastodonApi.notificationsWithAuth( authHeader, @@ -214,16 +141,17 @@ class NotificationFetcher @Inject constructor( // 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") + 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) + accountManager.updateAccount(account) { copy(notificationMarkerId = newMarkerId) } } + Log.d(TAG, "Got ${notifications.size} Notifications.") + return notifications } @@ -245,12 +173,5 @@ class NotificationFetcher @Inject constructor( companion object { 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/systemnotifications/NotificationService.kt b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationService.kt new file mode 100644 index 000000000..fd15cb6a8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationService.kt @@ -0,0 +1,1005 @@ +package com.keylesspalace.tusky.components.systemnotifications + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationChannelGroup +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.graphics.BitmapFactory +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import android.service.notification.StatusBarNotification +import android.util.Log +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.NotificationManagerCompat.NotificationWithIdAndTag +import androidx.core.app.RemoteInput +import androidx.core.app.TaskStackBuilder +import androidx.core.net.toUri +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.OutOfQuotaPolicy +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.onFailure +import at.connyduck.calladapter.networkresult.onSuccess +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.MainActivity +import com.keylesspalace.tusky.MainActivity.Companion.composeIntent +import com.keylesspalace.tusky.MainActivity.Companion.openNotificationIntent +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.entity.AccountEntity +import com.keylesspalace.tusky.di.ApplicationScope +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.NotificationSubscribeResult +import com.keylesspalace.tusky.entity.RelationshipSeveranceEvent +import com.keylesspalace.tusky.entity.visibleNotificationTypes +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.CryptoUtil +import com.keylesspalace.tusky.util.parseAsMastodonHtml +import com.keylesspalace.tusky.util.unicodeWrap +import com.keylesspalace.tusky.viewdata.buildDescription +import com.keylesspalace.tusky.viewdata.calculatePercent +import com.keylesspalace.tusky.worker.NotificationWorker +import dagger.hilt.android.qualifiers.ApplicationContext +import java.text.NumberFormat +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.unifiedpush.android.connector.UnifiedPush +import retrofit2.HttpException + +@Singleton +class NotificationService @Inject constructor( + private val notificationManager: NotificationManager, + private val accountManager: AccountManager, + private val api: MastodonApi, + private val preferences: SharedPreferences, + @ApplicationContext private val context: Context, + @ApplicationScope private val applicationScope: CoroutineScope, +) { + private var workManager: WorkManager = WorkManager.getInstance(context) + + private var notificationId: Int = NOTIFICATION_ID_PRUNE_CACHE + 1 + + init { + createWorkerNotificationChannel() + } + + fun areNotificationsEnabledBySystem(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // on Android >= O, notifications are enabled, if at least one channel is enabled + + if (notificationManager.areNotificationsEnabled()) { + for (channel in notificationManager.notificationChannels) { + if (channel != null && channel.importance > NotificationManager.IMPORTANCE_NONE) { + Log.d(TAG, "Notifications enabled for app by the system.") + return true + } + } + } + Log.d(TAG, "Notifications disabled for app by the system.") + + return false + } else { + // on Android < O, notifications are enabled, if at least one account has notification enabled + return accountManager.areNotificationsEnabled() + } + } + + suspend fun setupNotifications(account: AccountEntity?) { + resetPushWhenDistributorIsMissing() + + if (arePushNotificationsAvailable()) { + setupPushNotifications(account) + } + + // At least as a fallback and otherwise as main source when there are no push distributors installed: + enablePullNotifications() + } + + fun enablePullNotifications() { + val workRequest: PeriodicWorkRequest = PeriodicWorkRequest.Builder( + NotificationWorker::class.java, + PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS, + TimeUnit.MILLISECONDS, + ) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .build() + + workManager.enqueueUniquePeriodicWork(NOTIFICATION_PULL_NAME, ExistingPeriodicWorkPolicy.KEEP, workRequest) + + Log.d(TAG, "Enabled pull checks with ${PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS / 60000} minutes interval.") + } + + fun createNotificationChannelsForAccount(account: AccountEntity) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channelGroup = NotificationChannelGroup(account.identifier, account.fullName) + notificationManager.createNotificationChannelGroup(channelGroup) + + val channels = NotificationChannelData.entries.map { + NotificationChannel( + it.getChannelId(account), + context.getString(it.title), + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = context.getString(it.description) + enableLights(true) + lightColor = -0xd46f27 + enableVibration(true) + setShowBadge(true) + group = account.identifier + } + } + + notificationManager.createNotificationChannels(channels) + } + } + + private fun deleteNotificationChannelsForAccount(account: AccountEntity) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + notificationManager.deleteNotificationChannelGroup(account.identifier) + } + } + + private fun enqueueOneTimeWorker(account: AccountEntity?) { + val oneTimeRequestBuilder = OneTimeWorkRequest.Builder(NotificationWorker::class.java) + .setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + + account?.let { + val data = Data.Builder() + data.putLong(NotificationWorker.KEY_ACCOUNT_ID, account.id) + oneTimeRequestBuilder.setInputData(data.build()) + } + + workManager.enqueue(oneTimeRequestBuilder.build()) + } + + fun disablePullNotifications() { + workManager.cancelUniqueWork(NOTIFICATION_PULL_NAME) + Log.d(TAG, "Disabled pull checks.") + } + + fun clearNotificationsForAccount(account: AccountEntity) { + for (androidNotification in notificationManager.activeNotifications) { + if (account.id.toInt() == androidNotification.id) { + notificationManager.cancel(androidNotification.tag, androidNotification.id) + } + } + } + + fun filterNotification(account: AccountEntity, type: Notification.Type): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channelId = getChannelId(account, type) + ?: // unknown notificationtype + return false + val channel = notificationManager.getNotificationChannel(channelId) + + return channel != null && channel.importance > NotificationManager.IMPORTANCE_NONE + } + + return when (type) { + Notification.Type.Mention -> account.notificationsMentioned + Notification.Type.Status -> account.notificationsSubscriptions + Notification.Type.Follow -> account.notificationsFollowed + Notification.Type.FollowRequest -> account.notificationsFollowRequested + Notification.Type.Reblog -> account.notificationsReblogged + Notification.Type.Favourite -> account.notificationsFavorited + Notification.Type.Poll -> account.notificationsPolls + Notification.Type.SignUp -> account.notificationsAdmin + Notification.Type.Update -> account.notificationsUpdates + Notification.Type.Report -> account.notificationsAdmin + else -> account.notificationsOther + } + } + + fun show(account: AccountEntity, notifications: List) { + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + return + } + + if (notifications.isEmpty()) { + return + } + + val newNotifications = ArrayList() + + val notificationsByType: Map> = notifications.groupBy { it.type } + for ((type, notificationsForOneType) in notificationsByType) { + val summary = createSummaryNotification(account, type, notificationsForOneType) ?: continue + + // NOTE Enqueue the summary first: Needed to avoid rate limit problems: + // ie. single notification is enqueued but later the summary one is filtered and thus no grouping + // takes place. + newNotifications.add(summary) + + for (notification in notificationsForOneType) { + val single = createNotification(notification, account) ?: continue + newNotifications.add(single) + } + } + + val notificationManagerCompat = NotificationManagerCompat.from(context) + // NOTE having multiple summary notifications: this here should still collapse them in only one occurrence + notificationManagerCompat.notify(newNotifications) + } + + private fun createNotification(apiNotification: Notification, account: AccountEntity): NotificationWithIdAndTag? { + val baseNotification = createBaseNotification(apiNotification, account) ?: return null + + return NotificationWithIdAndTag( + apiNotification.id, + account.id.toInt(), + baseNotification + ) + } + + @VisibleForTesting + fun createBaseNotification(apiNotification: Notification, account: AccountEntity): android.app.Notification? { + val channelId = getChannelId(account, apiNotification.type) ?: return null + + val body = apiNotification.rewriteToStatusTypeIfNeeded(account.accountId) + + // Check for an existing notification matching this account and api notification + var existingAndroidNotification: android.app.Notification? = null + val activeNotifications = notificationManager.activeNotifications + for (androidNotification in activeNotifications) { + if (body.id == androidNotification.tag && account.id.toInt() == androidNotification.id) { + existingAndroidNotification = androidNotification.notification + } + } + + notificationId++ + + val builder = if (existingAndroidNotification == null) { + getNotificationBuilder(body, account, channelId) + } else { + NotificationCompat.Builder(context, existingAndroidNotification) + } + + builder + .setContentTitle(titleForType(body, account)) + .setContentText(bodyForType(body, account)) + + if (body.type == Notification.Type.Mention || body.type == Notification.Type.Poll) { + builder.setStyle( + NotificationCompat.BigTextStyle() + .bigText(bodyForType(body, account)) + ) + } + + if (body.type != Notification.Type.SeveredRelationship && body.type != Notification.Type.ModerationWarning) { + val accountAvatar = try { + Glide.with(context) + .asBitmap() + .load(body.account.avatar) + .transform(RoundedCorners(20)) + .submit() + .get() + } catch (e: ExecutionException) { + Log.d(TAG, "Error loading account avatar", e) + BitmapFactory.decodeResource(context.resources, R.drawable.avatar_default) + } catch (e: InterruptedException) { + Log.d(TAG, "Error loading account avatar", e) + BitmapFactory.decodeResource(context.resources, R.drawable.avatar_default) + } + + builder.setLargeIcon(accountAvatar) + } + + // Reply to mention action; RemoteInput is available from KitKat Watch, but buttons are available from Nougat + if (body.type == Notification.Type.Mention) { + val replyRemoteInput = RemoteInput.Builder(KEY_REPLY) + .setLabel(context.getString(R.string.label_quick_reply)) + .build() + + val quickReplyPendingIntent = getStatusReplyIntent(body, account, notificationId) + + val quickReplyAction = + NotificationCompat.Action.Builder( + R.drawable.ic_reply_24dp, + context.getString(R.string.action_quick_reply), + quickReplyPendingIntent + ) + .addRemoteInput(replyRemoteInput) + .build() + + builder.addAction(quickReplyAction) + + val composeIntent = getStatusComposeIntent(body, account, notificationId) + + val composeAction = + NotificationCompat.Action.Builder( + R.drawable.ic_reply_24dp, + context.getString(R.string.action_compose_shortcut), + composeIntent + ) + .setShowsUserInterface(true) + .build() + + builder.addAction(composeAction) + } + + builder.addExtras( + Bundle().apply { + // Add the sending account's name, so it can be used also later when summarising this notification + putString(EXTRA_ACCOUNT_NAME, body.account.name) + putString(EXTRA_NOTIFICATION_TYPE, body.type.name) + } + ) + + return builder.build() + } + + /** + * Create a notification that summarises the other notifications in this group. + * + * NOTE: We always create a summary notification (even for only one notification of that type): + * - No need to especially track the grouping + * - No need to change an existing single notification when there arrives another one of its group + * - Only the summary one will get announced + */ + private fun createSummaryNotification(account: AccountEntity, type: Notification.Type, additionalNotifications: List): NotificationWithIdAndTag? { + val typeChannelId = getChannelId(account, type) ?: return null + + val summaryStackBuilder = TaskStackBuilder.create(context) + summaryStackBuilder.addParentStack(MainActivity::class.java) + val summaryResultIntent = openNotificationIntent(context, account.id, type) + summaryStackBuilder.addNextIntent(summaryResultIntent) + + val summaryResultPendingIntent = summaryStackBuilder.getPendingIntent( + (notificationId + account.id * 10000).toInt(), + pendingIntentFlags(false) + ) + + val activeNotifications = getActiveNotifications(account.id, typeChannelId) + + val notificationCount = activeNotifications.size + additionalNotifications.size + + val title = context.resources.getQuantityString(R.plurals.notification_title_summary, notificationCount, notificationCount) + val text = joinNames(activeNotifications, additionalNotifications) + + val summaryBuilder = NotificationCompat.Builder(context, typeChannelId) + .setSmallIcon(R.drawable.ic_notify) + .setContentIntent(summaryResultPendingIntent) + .setColor(context.getColor(R.color.notification_color)) + .setAutoCancel(true) + .setContentTitle(title) + .setContentText(text) + .setShortcutId(account.id.toString()) + .setSubText(account.fullName) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setCategory(NotificationCompat.CATEGORY_SOCIAL) + .setGroup(typeChannelId) + .setGroupSummary(true) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + + setSoundVibrationLight(account, summaryBuilder) + + val summaryTag = "$GROUP_SUMMARY_TAG.$typeChannelId" + + return NotificationWithIdAndTag(summaryTag, account.id.toInt(), summaryBuilder.build()) + } + + fun createWorkerNotification(@StringRes titleResource: Int): android.app.Notification { + val title = context.getString(titleResource) + return NotificationCompat.Builder(context, CHANNEL_BACKGROUND_TASKS) + .setContentTitle(title) + .setTicker(title) + .setSmallIcon(R.drawable.ic_notify) + .setOngoing(true) + .build() + } + + private fun getChannelId(account: AccountEntity, type: Notification.Type): String? { + return NotificationChannelData.entries.find { data -> + data.notificationTypes.contains(type) + }?.getChannelId(account) + } + + /** + * Return all active notifications, ignoring notifications that: + * - belong to a different account + * - belong to a different type + * - are summary notifications + */ + private fun getActiveNotifications(accountId: Long, typeChannelId: String): List { + return notificationManager.activeNotifications.filter { + val channelId = it.notification.group + it.id == accountId.toInt() && channelId == typeChannelId && it.tag != "$GROUP_SUMMARY_TAG.$channelId" + } + } + + private fun getNotificationBuilder(notification: Notification, account: AccountEntity, channelId: String): NotificationCompat.Builder { + val notificationType = notification.type + val eventResultPendingIntent = if (notificationType == Notification.Type.ModerationWarning) { + val warning = notification.moderationWarning!! + val intent = Intent(Intent.ACTION_VIEW, "https://${account.domain}/disputes/strikes/${warning.id}".toUri()) + PendingIntent.getActivity(context, account.id.toInt(), intent, pendingIntentFlags(false)) + } else { + val eventResultIntent = openNotificationIntent(context, account.id, notificationType) + + val eventStackBuilder = TaskStackBuilder.create(context) + eventStackBuilder.addParentStack(MainActivity::class.java) + eventStackBuilder.addNextIntent(eventResultIntent) + + eventStackBuilder.getPendingIntent( + account.id.toInt(), + pendingIntentFlags(false) + ) + } + + val builder = NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_notify) + .setContentIntent(eventResultPendingIntent) + .setColor(context.getColor(R.color.notification_color)) + .setAutoCancel(true) + .setShortcutId(account.id.toString()) + .setSubText(account.fullName) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setCategory(NotificationCompat.CATEGORY_SOCIAL) + .setOnlyAlertOnce(true) + .setGroup(channelId) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) // Only ever alert for the summary notification + + setSoundVibrationLight(account, builder) + + return builder + } + + private fun titleForType(notification: Notification, account: AccountEntity): String? { + val accountName = notification.account.name.unicodeWrap() + when (notification.type) { + Notification.Type.Mention -> return context.getString(R.string.notification_mention_format, accountName) + Notification.Type.Status -> return context.getString(R.string.notification_subscription_format, accountName) + Notification.Type.Follow -> return context.getString(R.string.notification_follow_format, accountName) + Notification.Type.FollowRequest -> return context.getString(R.string.notification_follow_request_format, accountName) + Notification.Type.Favourite -> return context.getString(R.string.notification_favourite_format, accountName) + Notification.Type.Reblog -> return context.getString(R.string.notification_reblog_format, accountName) + Notification.Type.Poll -> return if (notification.status!!.account.id == account.accountId) { + context.getString(R.string.poll_ended_created) + } else { + context.getString(R.string.poll_ended_voted) + } + Notification.Type.SignUp -> return context.getString(R.string.notification_sign_up_format, accountName) + Notification.Type.Update -> return context.getString(R.string.notification_update_format, accountName) + Notification.Type.Report -> return context.getString(R.string.notification_report_format, account.domain) + Notification.Type.SeveredRelationship -> return context.getString(R.string.relationship_severance_event_title) + Notification.Type.ModerationWarning -> return context.getString(R.string.moderation_warning) + is Notification.Type.Unknown -> return null + } + } + + private fun bodyForType(notification: Notification, account: AccountEntity): String? { + val alwaysOpenSpoiler = account.alwaysOpenSpoiler + + when (notification.type) { + Notification.Type.Follow, Notification.Type.FollowRequest, Notification.Type.SignUp -> return "@" + notification.account.username + Notification.Type.Mention, Notification.Type.Favourite, Notification.Type.Reblog, Notification.Type.Status -> return if (!notification.status?.spoilerText.isNullOrEmpty() && !alwaysOpenSpoiler) { + notification.status.spoilerText + } else { + notification.status?.content?.parseAsMastodonHtml()?.toString() + } + Notification.Type.Poll -> if (!notification.status?.spoilerText.isNullOrEmpty() && !alwaysOpenSpoiler) { + return notification.status.spoilerText + } else { + val poll = notification.status?.poll ?: return null + + val builder = StringBuilder(notification.status.content.parseAsMastodonHtml()) + builder.append('\n') + + poll.options.forEachIndexed { i, option -> + builder.append( + buildDescription( + option.title, + calculatePercent(option.votesCount, poll.votersCount, poll.votesCount), + poll.ownVotes.contains(i), + context + ) + ) + builder.append('\n') + } + + return builder.toString() + } + Notification.Type.Report -> return context.getString( + R.string.notification_header_report_format, + notification.account.name.unicodeWrap(), + notification.report!!.targetAccount.name.unicodeWrap() + ) + Notification.Type.SeveredRelationship -> return severedRelationShipText(context, notification.event!!, account.domain) + Notification.Type.ModerationWarning -> return context.getString(notification.moderationWarning!!.action.text) + else -> return null + } + } + + private fun createWorkerNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + + val channel = NotificationChannel( + CHANNEL_BACKGROUND_TASKS, + context.getString(R.string.notification_listenable_worker_name), + NotificationManager.IMPORTANCE_NONE + ) + + channel.description = context.getString(R.string.notification_listenable_worker_description) + channel.enableLights(false) + channel.enableVibration(false) + channel.setShowBadge(false) + + notificationManager.createNotificationChannel(channel) + } + + private fun setSoundVibrationLight(account: AccountEntity, builder: NotificationCompat.Builder) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return // Do nothing on Android O or newer, the system uses only the channel settings + } + + builder.setDefaults(0) + + if (account.notificationSound) { + builder.setSound(Settings.System.DEFAULT_NOTIFICATION_URI) + } + + if (account.notificationVibration) { + builder.setVibrate(longArrayOf(500, 500)) + } + + if (account.notificationLight) { + builder.setLights(-0xd46f27, 300, 1000) + } + } + + private fun joinNames(notifications1: List, notifications2: List): String? { + val names = java.util.ArrayList(notifications1.size + notifications2.size) + + for (notification in notifications1) { + val author = notification.notification.extras.getString(EXTRA_ACCOUNT_NAME) ?: continue + names.add(author) + } + + for (noti in notifications2) { + names.add(noti.account.name) + } + + if (names.size > 3) { + val length = names.size + return context.getString( + R.string.notification_summary_large, + names[length - 1].unicodeWrap(), + names[length - 2].unicodeWrap(), + names[length - 3].unicodeWrap(), + length - 3 + ) + } else if (names.size == 3) { + return context.getString( + R.string.notification_summary_medium, + names[2].unicodeWrap(), + names[1].unicodeWrap(), + names[0].unicodeWrap() + ) + } else if (names.size == 2) { + return context.getString( + R.string.notification_summary_small, + names[1].unicodeWrap(), + names[0].unicodeWrap() + ) + } + + return null + } + + private fun getStatusReplyIntent(apiNotification: Notification, account: AccountEntity, requestCode: Int): PendingIntent { + val status = checkNotNull(apiNotification.status) + + val inReplyToId = status.id + val actionableStatus = status.actionableStatus + val replyVisibility = actionableStatus.visibility + val contentWarning = actionableStatus.spoilerText + val mentions = actionableStatus.mentions + + val mentionedUsernames = buildSet { + add(actionableStatus.account.username) + for (mention in mentions) { + add(mention.username) + } + remove(account.username) + } + + val replyIntent = Intent(context, SendStatusBroadcastReceiver::class.java) + .setAction(REPLY_ACTION) + .putExtra(KEY_SENDER_ACCOUNT_ID, account.id) + .putExtra(KEY_SENDER_ACCOUNT_IDENTIFIER, account.identifier) + .putExtra(KEY_SENDER_ACCOUNT_FULL_NAME, account.fullName) + .putExtra(KEY_SERVER_NOTIFICATION_ID, apiNotification.id) + .putExtra(KEY_CITED_STATUS_ID, inReplyToId) + .putExtra(KEY_VISIBILITY, replyVisibility) + .putExtra(KEY_SPOILER, contentWarning) + .putExtra(KEY_MENTIONS, mentionedUsernames.toTypedArray()) + + return PendingIntent.getBroadcast( + context.applicationContext, + requestCode, + replyIntent, + pendingIntentFlags(true) + ) + } + + private fun getStatusComposeIntent(apiNotification: Notification, account: AccountEntity, requestCode: Int): PendingIntent { + val status = checkNotNull(apiNotification.status) + + val citedLocalAuthor = status.account.localUsername + val citedText = status.content.parseAsMastodonHtml().toString() + val inReplyToId = status.id + val actionableStatus = status.actionableStatus + val replyVisibility = actionableStatus.visibility + val contentWarning = actionableStatus.spoilerText + val mentions = actionableStatus.mentions + + val mentionedUsernames = buildSet { + add(actionableStatus.account.username) + for (mention in mentions) { + add(mention.username) + } + remove(account.username) + } + + val composeOptions = ComposeOptions() + composeOptions.inReplyToId = inReplyToId + composeOptions.replyVisibility = replyVisibility + composeOptions.contentWarning = contentWarning + composeOptions.replyingStatusAuthor = citedLocalAuthor + composeOptions.replyingStatusContent = citedText + composeOptions.mentionedUsernames = mentionedUsernames + composeOptions.modifiedInitialState = true + composeOptions.language = actionableStatus.language + composeOptions.kind = ComposeActivity.ComposeKind.NEW + + val composeIntent = composeIntent(context, composeOptions, account.id, apiNotification.id, account.id.toInt()) + + // make sure a new instance of MainActivity is started and old ones get destroyed + composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + + return PendingIntent.getActivity( + context.applicationContext, + requestCode, + composeIntent, + pendingIntentFlags(false) + ) + } + + private fun pendingIntentFlags(mutable: Boolean): Int { + return if (mutable) { + PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0) + } else { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + } + } + + suspend fun disableAllNotifications() { + disablePushNotificationsForAllAccounts() + disablePullNotifications() + } + + suspend fun disableNotificationsForAccount(account: AccountEntity) { + disablePushNotificationsForAccount(account) + + deleteNotificationChannelsForAccount(account) + + if (!areNotificationsEnabledBySystem()) { + // TODO this is sort of a hack, it means: are there now no active accounts? + + disablePullNotifications() + } + } + + // + // Push notification section + // + + fun arePushNotificationsAvailable(): Boolean = + UnifiedPush.getDistributors(context).isNotEmpty() + + private suspend fun setupPushNotifications(account: AccountEntity?) { + val relevantAccounts: List = if (account != null) { + listOf(account) + } else { + accountManager.accounts + } + + relevantAccounts.forEach { + val notificationGroupEnabled = Build.VERSION.SDK_INT < 28 || + notificationManager.getNotificationChannelGroup(it.identifier)?.isBlocked == false + val shouldEnable = it.notificationsEnabled && notificationGroupEnabled + + if (shouldEnable) { + setupPushNotificationsForAccount(it) + Log.d(TAG, "Enabled push notifications for account ${it.id}.") + } else { + disablePushNotificationsForAccount(it) + Log.d(TAG, "Disabled push notifications for account ${it.id}.") + } + } + } + + private suspend fun setupPushNotificationsForAccount(account: AccountEntity) { + val currentSubscription = getActiveSubscription(account) + + if (currentSubscription != null) { + val alertData = buildAlertsMap(account) + + if (alertData != currentSubscription.alerts) { + // Update the subscription to match notification settings + updatePushSubscription(account) + } else { + Log.d(TAG, "Nothing to be done. Current push subscription matches for account ${account.id}.") + } + } else { + Log.d(TAG, "Trying to create a UnifiedPush subscription for account ${account.id}") + + // When changing the local UP distributor this is necessary first to enable the following callbacks (i. e. onNewEndpoint); + // make sure this is done in any inconsistent case (is not too often and doesn't hurt). + unregisterPushEndpoint(account) + + UnifiedPush.registerAppWithDialog(context, account.id.toString(), features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE)) + // Will lead to call of registerPushEndpoint() + } + } + + private fun resetPushWhenDistributorIsMissing() { + val lastUsedPushProvider = preferences.getString(PrefKeys.LAST_USED_PUSH_PROVDER, null) + // NOTE UnifiedPush.getSavedDistributor() cannot be used here as that is already null here if the + // distributor was uninstalled. + + if (lastUsedPushProvider.isNullOrEmpty() || UnifiedPush.getDistributors(context).contains(lastUsedPushProvider)) { + return + } + + Log.w(TAG, "Previous push provider ($lastUsedPushProvider) uninstalled. Resetting all accounts.") + + val editor = preferences.edit() + editor.remove(PrefKeys.LAST_USED_PUSH_PROVDER) + editor.apply() + + applicationScope.launch { + accountManager.accounts.forEach { + // reset all accounts, also does resetPushSettingsInAccount() + unregisterPushEndpoint(it) + } + } + } + + private suspend fun getActiveSubscription(account: AccountEntity): NotificationSubscribeResult? { + api.pushNotificationSubscription( + "Bearer ${account.accessToken}", + account.domain + ).fold( + onSuccess = { + if (!account.matchesPushSubscription(it.endpoint)) { + Log.w(TAG, "Server push endpoint does not match previously registered one: ${it.endpoint} vs. ${account.unifiedPushUrl}") + + return null + } + + return it + }, + onFailure = { throwable -> + if (throwable is HttpException && throwable.code() == 404) { + // this is alright; there is no subscription on the server + return null + } + + Log.e(TAG, "Cannot get push subscription for account " + account.id + ": " + throwable.message, throwable) + return null + } + ) + } + + private suspend fun disablePushNotificationsForAllAccounts() { + accountManager.accounts.forEach { + disablePushNotificationsForAccount(it) + } + } + + private suspend fun disablePushNotificationsForAccount(account: AccountEntity) { + if (!account.isPushNotificationsEnabled()) { + return + } + + unregisterPushEndpoint(account) + + // this probably does nothing (distributor to handle this is missing) + UnifiedPush.unregisterApp(context, account.id.toString()) + } + + fun fetchNotificationsOnPushMessage(account: AccountEntity) { + // TODO should there be a rate limit here? Ie. we could be silent (can we?) for another notification in a short timeframe. + + Log.d(TAG, "Fetching notifications because of push for account ${account.id}") + + enqueueOneTimeWorker(account) + } + + private fun buildAlertsMap(account: AccountEntity): Map = + buildMap { + visibleNotificationTypes.forEach { + put(it.name, filterNotification(account, it)) + } + } + + private fun buildAlertSubscriptionData(account: AccountEntity): Map = + buildAlertsMap(account).mapKeys { "data[alerts][${it.key}]" } + + // Called by UnifiedPush callback in UnifiedPushBroadcastReceiver + suspend fun registerPushEndpoint( + account: AccountEntity, + endpoint: String + ) = withContext(Dispatchers.IO) { + // Generate a prime256v1 key pair for WebPush + // Decryption is unimplemented for now, since Mastodon uses an old WebPush + // standard which does not send needed information for decryption in the payload + // This makes it not directly compatible with UnifiedPush + // As of now, we use it purely as a way to trigger a pull + val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1) + val auth = CryptoUtil.secureRandomBytesEncoded(16) + + api.subscribePushNotifications( + "Bearer ${account.accessToken}", + account.domain, + endpoint, + keyPair.pubkey, + auth, + buildAlertSubscriptionData(account) + ).onFailure { throwable -> + Log.w(TAG, "Error setting push endpoint for account ${account.id}", throwable) + disablePushNotificationsForAccount(account) + }.onSuccess { + Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}") + + accountManager.updateAccount(account) { + copy( + pushPubKey = keyPair.pubkey, + pushPrivKey = keyPair.privKey, + pushAuth = auth, + pushServerKey = it.serverKey, + unifiedPushUrl = endpoint + ) + } + + UnifiedPush.getAckDistributor(context)?.let { + Log.d(TAG, "Saving distributor to preferences: $it") + + val editor = preferences.edit() + editor.putString(PrefKeys.LAST_USED_PUSH_PROVDER, it) + editor.apply() + + // TODO once this is selected it cannot be changed (except by wiping the application or uninstalling the provider) + } + } + } + + // Synchronize the enabled / disabled state of notifications with server-side subscription (also NotificationBlockStateBroadcastReceiver). + suspend fun updatePushSubscription(account: AccountEntity) { + withContext(Dispatchers.IO) { + api.updatePushNotificationSubscription( + "Bearer ${account.accessToken}", + account.domain, + buildAlertSubscriptionData(account) + ).onSuccess { + Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}") + accountManager.updateAccount(account) { + copy(pushServerKey = it.serverKey) + } + }.onFailure { throwable -> + Log.e(TAG, "Could not update subscription ${throwable.message}") + } + } + } + + suspend fun unregisterPushEndpoint(account: AccountEntity) { + withContext(Dispatchers.IO) { + api.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain) + .onFailure { throwable -> + Log.w(TAG, "Error unregistering push endpoint for account " + account.id, throwable) + } + .onSuccess { + Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id) + resetPushSettingsInAccount(account) + } + } + } + + private suspend fun resetPushSettingsInAccount(account: AccountEntity) { + accountManager.updateAccount(account) { + copy( + pushPubKey = "", + pushPrivKey = "", + pushAuth = "", + pushServerKey = "", + unifiedPushUrl = "" + ) + } + } + + companion object { + const val TAG = "NotificationService" + + const val KEY_CITED_STATUS_ID: String = "KEY_CITED_STATUS_ID" + const val KEY_MENTIONS: String = "KEY_MENTIONS" + const val KEY_REPLY: String = "KEY_REPLY" + const val KEY_SENDER_ACCOUNT_FULL_NAME: String = "KEY_SENDER_ACCOUNT_FULL_NAME" + const val KEY_SENDER_ACCOUNT_ID: String = "KEY_SENDER_ACCOUNT_ID" + const val KEY_SENDER_ACCOUNT_IDENTIFIER: String = "KEY_SENDER_ACCOUNT_IDENTIFIER" + const val KEY_SERVER_NOTIFICATION_ID: String = "KEY_SERVER_NOTIFICATION_ID" + const val KEY_SPOILER: String = "KEY_SPOILER" + const val KEY_VISIBILITY: String = "KEY_VISIBILITY" + const val NOTIFICATION_ID_FETCH_NOTIFICATION: Int = 0 + const val NOTIFICATION_ID_PRUNE_CACHE: Int = 1 + const val REPLY_ACTION: String = "REPLY_ACTION" + + private const val CHANNEL_BACKGROUND_TASKS: String = "CHANNEL_BACKGROUND_TASKS" + private const val EXTRA_ACCOUNT_NAME = BuildConfig.APPLICATION_ID + ".notification.extra.account_name" + private const val EXTRA_NOTIFICATION_TYPE = BuildConfig.APPLICATION_ID + ".notification.extra.notification_type" + private const val GROUP_SUMMARY_TAG = BuildConfig.APPLICATION_ID + ".notification.group_summary" + private const val NOTIFICATION_PULL_NAME = "pullNotifications" + + private val numberFormat = NumberFormat.getNumberInstance() + + fun severedRelationShipText( + context: Context, + event: RelationshipSeveranceEvent, + instanceName: String + ): String { + return when (event.type) { + RelationshipSeveranceEvent.Type.DOMAIN_BLOCK -> { + val followers = numberFormat.format(event.followersCount) + val following = numberFormat.format(event.followingCount) + val followingText = context.resources.getQuantityString(R.plurals.accounts, event.followingCount, following) + context.getString(R.string.relationship_severance_event_domain_block, instanceName, event.targetName, followers, followingText) + } + + RelationshipSeveranceEvent.Type.USER_DOMAIN_BLOCK -> { + val followers = numberFormat.format(event.followersCount) + val following = numberFormat.format(event.followingCount) + val followingText = context.resources.getQuantityString(R.plurals.accounts, event.followingCount, following) + context.getString(R.string.relationship_severance_event_user_domain_block, event.targetName, followers, followingText) + } + + RelationshipSeveranceEvent.Type.ACCOUNT_SUSPENSION -> { + context.getString(R.string.relationship_severance_event_account_suspension, instanceName, event.targetName) + } + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index b5c9bf73b..4029dd67b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -15,22 +15,20 @@ package com.keylesspalace.tusky.components.timeline +import android.content.SharedPreferences import android.os.Bundle import android.util.Log -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 android.view.accessibility.AccessibilityManager -import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService import androidx.core.view.MenuProvider import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState -import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -41,7 +39,6 @@ import at.connyduck.sparkbutton.helpers.Utils import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent @@ -52,8 +49,6 @@ import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewM import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.databinding.FragmentTimelineBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.fragment.SFragment import com.keylesspalace.tusky.interfaces.ActionButtonActivity @@ -64,6 +59,7 @@ import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.CardViewMode import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.ensureBottomPadding import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation @@ -77,30 +73,32 @@ 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.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +@AndroidEntryPoint class TimelineFragment : - SFragment(), + SFragment(R.layout.fragment_timeline), OnRefreshListener, StatusActionListener, - Injectable, ReselectableFragment, RefreshableFragment, MenuProvider { - @Inject - lateinit var viewModelFactory: ViewModelFactory - @Inject lateinit var eventHub: EventHub + @Inject + lateinit var preferences: SharedPreferences + private val viewModel: TimelineViewModel by unsafeLazy { + val viewModelProvider = ViewModelProvider(viewModelStore, defaultViewModelProviderFactory, defaultViewModelCreationExtras) if (kind == TimelineViewModel.Kind.HOME) { - ViewModelProvider(this, viewModelFactory)[CachedTimelineViewModel::class.java] + viewModelProvider[CachedTimelineViewModel::class.java] } else { - ViewModelProvider(this, viewModelFactory)[NetworkTimelineViewModel::class.java] + viewModelProvider[NetworkTimelineViewModel::class.java] } } @@ -108,10 +106,9 @@ class TimelineFragment : private lateinit var kind: TimelineViewModel.Kind - private lateinit var adapter: TimelinePagingAdapter + private var adapter: TimelinePagingAdapter? = null private var isSwipeToRefreshEnabled = true - private var hideFab = false /** * Adapter position of the placeholder that was most recently clicked to "Load more". If null @@ -173,9 +170,10 @@ class TimelineFragment : isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) - val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) readingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null)) + } + private fun createAdapter(): TimelinePagingAdapter { val statusDisplayOptions = StatusDisplayOptions( animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, @@ -199,25 +197,20 @@ class TimelineFragment : showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) - adapter = TimelinePagingAdapter( + return TimelinePagingAdapter( statusDisplayOptions, this ) } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_timeline, container, false) - } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + val adapter = createAdapter() + this.adapter = adapter + setupSwipeRefreshLayout() - setupRecyclerView() + setupRecyclerView(adapter) adapter.addLoadStateListener { loadState -> if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) { @@ -258,7 +251,8 @@ class TimelineFragment : adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - if (positionStart == 0 && adapter.itemCount != itemCount) { + val firstPos = (binding.recyclerView.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() + if (firstPos == 0 && positionStart == 0 && adapter.itemCount != itemCount) { binding.recyclerView.post { if (getView() != null) { if (isSwipeToRefreshEnabled) { @@ -271,9 +265,11 @@ class TimelineFragment : } } } + // we loaded new posts at the top - no need to handle "load more" anymore + loadMorePosition = null } if (readingOrder == ReadingOrder.OLDEST_FIRST) { - updateReadingPositionForOldestFirst() + updateReadingPositionForOldestFirst(adapter) } } }) @@ -284,49 +280,28 @@ class TimelineFragment : } } - if (actionButtonPresent()) { - val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false) - binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { - val composeButton = (activity as ActionButtonActivity).actionButton - if (composeButton != null) { - if (hideFab) { - if (dy > 0 && composeButton.isShown) { - composeButton.hide() // hides the button if we're scrolling down - } else if (dy < 0 && !composeButton.isShown) { - composeButton.show() // shows it if we are scrolling up - } - } else if (!composeButton.isShown) { - composeButton.show() - } - } - } - }) - } - viewLifecycleOwner.lifecycleScope.launch { eventHub.events.collect { event -> when (event) { is PreferenceChangedEvent -> { - onPreferenceChanged(event.preferenceKey) + onPreferenceChanged(adapter, event.preferenceKey) } is StatusComposedEvent -> { val status = event.status - handleStatusComposeEvent(status) + handleStatusComposeEvent(adapter, status) } } } } - updateRelativeTimePeriodically { - adapter.notifyItemRangeChanged( - 0, - adapter.itemCount, - listOf(StatusBaseViewHolder.Key.KEY_CREATED) - ) - } + updateRelativeTimePeriodically(preferences, adapter) + } + + override fun onDestroyView() { + // Clear the adapter to prevent leaking the View + adapter = null + super.onDestroyView() } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { @@ -367,7 +342,7 @@ class TimelineFragment : // match the adapter position where data was inserted (which is why loadMorePosition // is tracked manually, see this bug report for another example: // https://github.com/android/architecture-components-samples/issues/726). - private fun updateReadingPositionForOldestFirst() { + private fun updateReadingPositionForOldestFirst(adapter: TimelinePagingAdapter) { var position = loadMorePosition ?: return val statusIdBelowLoadMore = statusIdBelowLoadMore ?: return @@ -393,10 +368,12 @@ class TimelineFragment : private fun setupSwipeRefreshLayout() { binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled binding.swipeRefreshLayout.setOnRefreshListener(this) - binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) } - private fun setupRecyclerView() { + private fun setupRecyclerView(adapter: TimelinePagingAdapter) { + val hasFab = (activity as? ActionButtonActivity?)?.actionButton != null + binding.recyclerView.ensureBottomPadding(fab = hasFab) + binding.recyclerView.setAccessibilityDelegateCompat( ListStatusAccessibilityDelegate(binding.recyclerView, this) { pos -> if (pos in 0 until adapter.itemCount) { @@ -406,8 +383,8 @@ class TimelineFragment : } } ) - binding.recyclerView.setHasFixedSize(true) binding.recyclerView.layoutManager = LinearLayoutManager(context) + val divider = DividerItemDecoration(context, RecyclerView.VERTICAL) binding.recyclerView.addItemDecoration(divider) @@ -419,7 +396,7 @@ class TimelineFragment : override fun onRefresh() { binding.statusView.hide() - adapter.refresh() + adapter?.refresh() } override val onMoreTranslate = @@ -434,18 +411,18 @@ class TimelineFragment : } override fun onReply(position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter?.peek(position)?.asStatusOrNull() ?: return super.reply(status.status) } - override fun onReblog(reblog: Boolean, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return - viewModel.reblog(reblog, status) + override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) { + val status = adapter?.peek(position)?.asStatusOrNull() ?: return + viewModel.reblog(reblog, status, visibility) } private fun onTranslate(position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return - lifecycleScope.launch { + val status = adapter?.peek(position)?.asStatusOrNull() ?: return + viewLifecycleOwner.lifecycleScope.launch { viewModel.translate(status) .onFailure { Snackbar.make( @@ -458,32 +435,32 @@ class TimelineFragment : } override fun onUntranslate(position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.untranslate(status) } override fun onFavourite(favourite: Boolean, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.favorite(favourite, status) } override fun onBookmark(bookmark: Boolean, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.bookmark(bookmark, status) } override fun onVoteInPoll(position: Int, choices: List) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.voteInPoll(choices, status) } override fun clearWarningAction(position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.clearWarning(status) } override fun onMore(view: View, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter?.peek(position)?.asStatusOrNull() ?: return super.more( status.status, view, @@ -493,34 +470,35 @@ class TimelineFragment : } override fun onOpenReblog(position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter?.peek(position)?.asStatusOrNull() ?: return super.openReblog(status.status) } override fun onExpandedChange(expanded: Boolean, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.changeExpanded(expanded, status) } override fun onContentHiddenChange(isShowing: Boolean, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.changeContentShowing(isShowing, status) } override fun onShowReblogs(position: Int) { - val statusId = adapter.peek(position)?.asStatusOrNull()?.id ?: return + val statusId = adapter?.peek(position)?.asStatusOrNull()?.id ?: return val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId) activity?.startActivityWithSlideInAnimation(intent) } override fun onShowFavs(position: Int) { - val statusId = adapter.peek(position)?.asStatusOrNull()?.id ?: return + val statusId = adapter?.peek(position)?.asStatusOrNull()?.id ?: return val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId) activity?.startActivityWithSlideInAnimation(intent) } override fun onLoadMore(position: Int) { - val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return + val adapter = this.adapter + val placeholder = adapter?.peek(position)?.asPlaceholderOrNull() ?: return loadMorePosition = position statusIdBelowLoadMore = if (position + 1 < adapter.itemCount) adapter.peek(position + 1)?.id else null @@ -528,21 +506,21 @@ class TimelineFragment : } override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.changeContentCollapsed(isCollapsed, status) } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter?.peek(position)?.asStatusOrNull() ?: return super.viewMedia( attachmentIndex, - AttachmentViewData.list(status.actionable), + AttachmentViewData.list(status), view ) } override fun onViewThread(position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter?.peek(position)?.asStatusOrNull() ?: return super.viewThread(status.actionable.id, status.actionable.url) } @@ -570,13 +548,8 @@ class TimelineFragment : super.viewAccount(id) } - private fun onPreferenceChanged(key: String) { - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + private fun onPreferenceChanged(adapter: TimelinePagingAdapter, key: String) { when (key) { - PrefKeys.FAB_HIDE -> { - hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) - } - PrefKeys.MEDIA_PREVIEW_ENABLED -> { val enabled = accountManager.activeAccount!!.mediaPreviewEnabled val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled @@ -588,13 +561,13 @@ class TimelineFragment : PrefKeys.READING_ORDER -> { readingOrder = ReadingOrder.from( - sharedPreferences.getString(PrefKeys.READING_ORDER, null) + preferences.getString(PrefKeys.READING_ORDER, null) ) } } } - private fun handleStatusComposeEvent(status: Status) { + private fun handleStatusComposeEvent(adapter: TimelinePagingAdapter, status: Status) { when (kind) { TimelineViewModel.Kind.HOME, TimelineViewModel.Kind.PUBLIC_FEDERATED, @@ -615,17 +588,10 @@ class TimelineFragment : } public override fun removeItem(position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.removeStatusWithId(status.id) } - private fun actionButtonPresent(): Boolean { - return viewModel.kind != TimelineViewModel.Kind.TAG && - viewModel.kind != TimelineViewModel.Kind.FAVOURITES && - viewModel.kind != TimelineViewModel.Kind.BOOKMARKS && - activity is ActionButtonActivity - } - private var talkBackWasEnabled = false override fun onPause() { @@ -633,7 +599,7 @@ class TimelineFragment : (binding.recyclerView.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition() ?.let { position -> if (position != RecyclerView.NO_POSITION) { - adapter.snapshot().getOrNull(position)?.id?.let { statusId -> + adapter?.snapshot()?.getOrNull(position)?.id?.let { statusId -> viewModel.saveReadingPosition(statusId) } } @@ -642,19 +608,19 @@ class TimelineFragment : override fun onResume() { super.onResume() - val a11yManager = - ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java) + val a11yManager = requireContext().getSystemService() val wasEnabled = talkBackWasEnabled talkBackWasEnabled = a11yManager?.isEnabled == true Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled") if (talkBackWasEnabled && !wasEnabled) { + val adapter = requireNotNull(this.adapter) adapter.notifyItemRangeChanged(0, adapter.itemCount) } } override fun onReselect() { - if (isAdded) { + if (view != null) { binding.recyclerView.layoutManager?.scrollToPosition(0) binding.recyclerView.stopScroll() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt index 716a30199..e6783a624 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt @@ -21,9 +21,12 @@ import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.FilteredStatusViewHolder import com.keylesspalace.tusky.adapter.PlaceholderViewHolder import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.adapter.StatusViewHolder +import com.keylesspalace.tusky.databinding.ItemStatusFilteredBinding +import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions @@ -50,11 +53,15 @@ class TimelinePagingAdapter( val inflater = LayoutInflater.from(viewGroup.context) return when (viewType) { VIEW_TYPE_STATUS_FILTERED -> { - StatusViewHolder(inflater.inflate(R.layout.item_status_wrapper, viewGroup, false)) + FilteredStatusViewHolder( + ItemStatusFilteredBinding.inflate(inflater, viewGroup, false), + statusListener + ) } VIEW_TYPE_PLACEHOLDER -> { PlaceholderViewHolder( - inflater.inflate(R.layout.item_status_placeholder, viewGroup, false) + ItemStatusPlaceholderBinding.inflate(inflater, viewGroup, false), + statusListener ) } else -> { @@ -64,34 +71,32 @@ class TimelinePagingAdapter( } override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) { - bindViewHolder(viewHolder, position, null) + onBindViewHolder(viewHolder, position, emptyList()) } override fun onBindViewHolder( viewHolder: RecyclerView.ViewHolder, position: Int, - payloads: List<*> + payloads: List ) { - bindViewHolder(viewHolder, position, payloads) - } - - private fun bindViewHolder( - viewHolder: RecyclerView.ViewHolder, - position: Int, - payloads: List<*>? - ) { - val status = getItem(position) - if (status is StatusViewData.Placeholder) { + val viewData = getItem(position) + if (viewData is StatusViewData.Placeholder) { val holder = viewHolder as PlaceholderViewHolder - holder.setup(statusListener, status.isLoading) - } else if (status is StatusViewData.Concrete) { - val holder = viewHolder as StatusViewHolder - holder.setupWithStatus( - status, - statusListener, - statusDisplayOptions, - if (payloads != null && payloads.isNotEmpty()) payloads[0] else null - ) + holder.setup(viewData.isLoading) + } else if (viewData is StatusViewData.Concrete) { + if (viewData.filterAction == Filter.Action.WARN) { + val holder = viewHolder as FilteredStatusViewHolder + holder.bind(viewData) + } else { + val holder = viewHolder as StatusViewHolder + holder.setupWithStatus( + viewData, + statusListener, + statusDisplayOptions, + payloads, + true + ) + } } } @@ -129,7 +134,7 @@ class TimelinePagingAdapter( override fun getChangePayload(oldItem: StatusViewData, newItem: StatusViewData): Any? { return if (oldItem == newItem) { // If items are equal - update timestamp only - listOf(StatusBaseViewHolder.Key.KEY_CREATED) + StatusBaseViewHolder.Key.KEY_CREATED } else { // If items are different - update the whole view holder null diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt index 4f8251d9e..b342dc2f6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt @@ -13,172 +13,156 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -@file:OptIn(ExperimentalStdlibApi::class) - package com.keylesspalace.tusky.components.timeline -import android.util.Log -import com.keylesspalace.tusky.db.TimelineAccountEntity -import com.keylesspalace.tusky.db.TimelineStatusEntity -import com.keylesspalace.tusky.db.TimelineStatusWithAccount -import com.keylesspalace.tusky.entity.Attachment -import com.keylesspalace.tusky.entity.Card -import com.keylesspalace.tusky.entity.Emoji -import com.keylesspalace.tusky.entity.HashTag -import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.db.entity.HomeTimelineData +import com.keylesspalace.tusky.db.entity.HomeTimelineEntity +import com.keylesspalace.tusky.db.entity.TimelineAccountEntity +import com.keylesspalace.tusky.db.entity.TimelineStatusEntity import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData -import com.squareup.moshi.Moshi -import com.squareup.moshi.adapter import java.util.Date -private const val TAG = "TimelineTypeMappers" - data class Placeholder( val id: String, val loading: Boolean ) -fun TimelineAccount.toEntity(accountId: Long, moshi: Moshi): TimelineAccountEntity { +fun TimelineAccount.toEntity(tuskyAccountId: Long): TimelineAccountEntity { return TimelineAccountEntity( serverId = id, - timelineUserId = accountId, + tuskyAccountId = tuskyAccountId, localUsername = localUsername, username = username, displayName = name, url = url, avatar = avatar, - emojis = moshi.adapter>().toJson(emojis), + emojis = emojis, + note = note, bot = bot ) } -fun TimelineAccountEntity.toAccount(moshi: Moshi): TimelineAccount { +fun TimelineAccountEntity.toAccount(): TimelineAccount { return TimelineAccount( id = serverId, localUsername = localUsername, username = username, displayName = displayName, - note = "", + note = note, url = url, avatar = avatar, bot = bot, - emojis = moshi.adapter?>().fromJson(emojis).orEmpty() + emojis = emojis ) } -fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { - return TimelineStatusEntity( - serverId = this.id, - url = null, - timelineUserId = timelineUserId, - authorServerId = null, - inReplyToId = null, - inReplyToAccountId = null, - content = null, - createdAt = 0L, - editedAt = 0L, - emojis = null, - reblogsCount = 0, - favouritesCount = 0, - reblogged = false, - favourited = false, - bookmarked = false, - sensitive = false, - spoilerText = "", - visibility = Status.Visibility.UNKNOWN, - attachments = null, - mentions = null, - tags = null, - application = null, - reblogServerId = null, +fun Placeholder.toEntity(tuskyAccountId: Long): HomeTimelineEntity { + return HomeTimelineEntity( + id = this.id, + tuskyAccountId = tuskyAccountId, + statusId = null, reblogAccountId = null, - poll = null, - muted = false, - expanded = loading, - contentCollapsed = false, - contentShowing = false, - pinned = false, - card = null, - repliesCount = 0, - language = null, - filtered = emptyList() + loading = this.loading ) } fun Status.toEntity( - timelineUserId: Long, - moshi: Moshi, + tuskyAccountId: Long, expanded: Boolean, contentShowing: Boolean, contentCollapsed: Boolean -): TimelineStatusEntity { - return TimelineStatusEntity( - serverId = this.id, - url = actionableStatus.url, - timelineUserId = timelineUserId, - authorServerId = actionableStatus.account.id, - inReplyToId = actionableStatus.inReplyToId, - inReplyToAccountId = actionableStatus.inReplyToAccountId, - content = actionableStatus.content, - createdAt = actionableStatus.createdAt.time, - editedAt = actionableStatus.editedAt?.time, - emojis = actionableStatus.emojis.let { moshi.adapter>().toJson(it) }, - reblogsCount = actionableStatus.reblogsCount, - favouritesCount = actionableStatus.favouritesCount, - reblogged = actionableStatus.reblogged, - favourited = actionableStatus.favourited, - bookmarked = actionableStatus.bookmarked, - sensitive = actionableStatus.sensitive, - spoilerText = actionableStatus.spoilerText, - visibility = actionableStatus.visibility, - attachments = actionableStatus.attachments.let { moshi.adapter>().toJson(it) }, - mentions = actionableStatus.mentions.let { moshi.adapter>().toJson(it) }, - tags = actionableStatus.tags.let { moshi.adapter?>().toJson(it) }, - application = actionableStatus.application.let { moshi.adapter().toJson(it) }, - reblogServerId = reblog?.id, - reblogAccountId = reblog?.let { this.account.id }, - poll = actionableStatus.poll.let { moshi.adapter().toJson(it) }, - muted = actionableStatus.muted, - expanded = expanded, - contentShowing = contentShowing, - contentCollapsed = contentCollapsed, - pinned = actionableStatus.pinned, - card = actionableStatus.card?.let { moshi.adapter().toJson(it) }, - repliesCount = actionableStatus.repliesCount, - language = actionableStatus.language, - filtered = actionableStatus.filtered - ) -} +) = TimelineStatusEntity( + serverId = id, + url = actionableStatus.url, + tuskyAccountId = tuskyAccountId, + authorServerId = actionableStatus.account.id, + inReplyToId = actionableStatus.inReplyToId, + inReplyToAccountId = actionableStatus.inReplyToAccountId, + content = actionableStatus.content, + createdAt = actionableStatus.createdAt.time, + editedAt = actionableStatus.editedAt?.time, + emojis = actionableStatus.emojis, + reblogsCount = actionableStatus.reblogsCount, + favouritesCount = actionableStatus.favouritesCount, + reblogged = actionableStatus.reblogged, + favourited = actionableStatus.favourited, + bookmarked = actionableStatus.bookmarked, + sensitive = actionableStatus.sensitive, + spoilerText = actionableStatus.spoilerText, + visibility = actionableStatus.visibility, + attachments = actionableStatus.attachments, + mentions = actionableStatus.mentions, + tags = actionableStatus.tags, + application = actionableStatus.application, + poll = actionableStatus.poll, + muted = actionableStatus.muted, + expanded = expanded, + contentShowing = contentShowing, + contentCollapsed = contentCollapsed, + pinned = actionableStatus.pinned, + card = actionableStatus.card, + repliesCount = actionableStatus.repliesCount, + language = actionableStatus.language, + filtered = actionableStatus.filtered.orEmpty() +) -fun TimelineStatusWithAccount.toViewData(moshi: Moshi, isDetailed: Boolean = false, translation: TranslationViewData? = null): StatusViewData { - if (this.account == null) { - Log.d(TAG, "Constructing Placeholder(${this.status.serverId}, ${this.status.expanded})") - return StatusViewData.Placeholder(this.status.serverId, this.status.expanded) +fun TimelineStatusEntity.toStatus( + account: TimelineAccountEntity, +) = Status( + id = serverId, + url = url, + account = account.toAccount(), + inReplyToId = inReplyToId, + inReplyToAccountId = inReplyToAccountId, + reblog = null, + content = content, + createdAt = Date(createdAt), + editedAt = editedAt?.let { Date(it) }, + emojis = emojis, + reblogsCount = reblogsCount, + favouritesCount = favouritesCount, + reblogged = reblogged, + favourited = favourited, + bookmarked = bookmarked, + sensitive = sensitive, + spoilerText = spoilerText, + visibility = visibility, + attachments = attachments, + mentions = mentions, + tags = tags, + application = application, + pinned = false, + muted = muted, + poll = poll, + card = card, + repliesCount = repliesCount, + language = language, + filtered = filtered, +) + +fun HomeTimelineData.toViewData(isDetailed: Boolean = false, translation: TranslationViewData? = null): StatusViewData { + if (this.account == null || this.status == null) { + return StatusViewData.Placeholder(this.id, loading) } - val attachments: List = status.attachments?.let { moshi.adapter?>().fromJson(it) }.orEmpty() - val mentions: List = status.mentions?.let { moshi.adapter?>().fromJson(it) }.orEmpty() - val tags: List? = status.tags?.let { moshi.adapter?>().fromJson(it) } - val application = status.application?.let { moshi.adapter().fromJson(it) } - val emojis: List = status.emojis?.let { moshi.adapter?>().fromJson(it) }.orEmpty() - val poll: Poll? = status.poll?.let { moshi.adapter().fromJson(it) } - val card: Card? = status.card?.let { moshi.adapter().fromJson(it) } - - val reblog = status.reblogServerId?.let { id -> + val originalStatus = status.toStatus(account) + val status = if (reblogAccount != null) { Status( id = id, - url = status.url, - account = account.toAccount(moshi), + // no url for reblogs + url = null, + account = reblogAccount.toAccount(), inReplyToId = status.inReplyToId, inReplyToAccountId = status.inReplyToAccountId, - reblog = null, - content = status.content.orEmpty(), + reblog = originalStatus, + content = status.content, + // lie but whatever? createdAt = Date(status.createdAt), - editedAt = status.editedAt?.let { Date(it) }, - emojis = emojis, + editedAt = null, + emojis = emptyList(), reblogsCount = status.reblogsCount, favouritesCount = status.favouritesCount, reblogged = status.reblogged, @@ -187,92 +171,29 @@ fun TimelineStatusWithAccount.toViewData(moshi: Moshi, isDetailed: Boolean = fal sensitive = status.sensitive, spoilerText = status.spoilerText, visibility = status.visibility, - attachments = attachments, - mentions = mentions, - tags = tags, - application = application, - pinned = false, - muted = status.muted ?: false, - poll = poll, - card = card, - repliesCount = status.repliesCount, - language = status.language, - filtered = status.filtered.orEmpty(), - ) - } - val status = if (reblog != null) { - Status( - id = status.serverId, - // no url for reblogs - url = null, - account = this.reblogAccount!!.toAccount(moshi), - inReplyToId = null, - inReplyToAccountId = null, - reblog = reblog, - content = "", - // lie but whatever? - createdAt = Date(status.createdAt), - editedAt = null, - emojis = emptyList(), - reblogsCount = 0, - favouritesCount = 0, - reblogged = false, - favourited = false, - bookmarked = false, - sensitive = false, - spoilerText = "", - visibility = status.visibility, attachments = emptyList(), mentions = emptyList(), tags = emptyList(), application = null, - pinned = status.pinned, - muted = status.muted ?: false, + pinned = false, + muted = status.muted, poll = null, card = null, repliesCount = status.repliesCount, language = status.language, - filtered = status.filtered.orEmpty() + filtered = status.filtered, ) } else { - Status( - id = status.serverId, - url = status.url, - account = account.toAccount(moshi), - inReplyToId = status.inReplyToId, - inReplyToAccountId = status.inReplyToAccountId, - reblog = null, - content = translation?.data?.content ?: status.content.orEmpty(), - createdAt = Date(status.createdAt), - editedAt = status.editedAt?.let { Date(it) }, - emojis = emojis, - reblogsCount = status.reblogsCount, - favouritesCount = status.favouritesCount, - reblogged = status.reblogged, - favourited = status.favourited, - bookmarked = status.bookmarked, - sensitive = status.sensitive, - spoilerText = status.spoilerText, - visibility = status.visibility, - attachments = attachments, - mentions = mentions, - tags = tags, - application = application, - pinned = status.pinned, - muted = status.muted ?: false, - poll = poll, - card = card, - repliesCount = status.repliesCount, - language = status.language, - filtered = status.filtered.orEmpty() - ) + originalStatus } + return StatusViewData.Concrete( status = status, isExpanded = this.status.expanded, isShowingContent = this.status.contentShowing, isCollapsed = this.status.contentCollapsed, isDetailed = isDetailed, + repliedToAccount = repliedToAccount?.toAccount(), translation = translation, ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 625cdf910..13a47848f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -24,33 +24,34 @@ import androidx.room.withTransaction import com.keylesspalace.tusky.components.timeline.Placeholder import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.util.ifExpected -import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.db.TimelineStatusEntity -import com.keylesspalace.tusky.db.TimelineStatusWithAccount +import com.keylesspalace.tusky.db.entity.AccountEntity +import com.keylesspalace.tusky.db.entity.HomeTimelineData +import com.keylesspalace.tusky.db.entity.HomeTimelineEntity +import com.keylesspalace.tusky.db.entity.TimelineStatusEntity import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi -import com.squareup.moshi.Moshi import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) class CachedTimelineRemoteMediator( - accountManager: AccountManager, + private val viewModel: CachedTimelineViewModel, private val api: MastodonApi, private val db: AppDatabase, - private val moshi: Moshi -) : RemoteMediator() { +) : RemoteMediator() { private var initialRefresh = false private val timelineDao = db.timelineDao() - private val activeAccount = accountManager.activeAccount!! + private val statusDao = db.timelineStatusDao() + private val accountDao = db.timelineAccountDao() override suspend fun load( loadType: LoadType, - state: PagingState + state: PagingState ): MediatorResult { - if (!activeAccount.isLoggedIn()) { + val activeAccount = viewModel.activeAccountFlow.value + if (activeAccount == null) { return MediatorResult.Success(endOfPaginationReached = true) } @@ -76,7 +77,7 @@ class CachedTimelineRemoteMediator( val statuses = statusResponse.body() if (statusResponse.isSuccessful && statuses != null) { db.withTransaction { - replaceStatusRange(statuses, state) + replaceStatusRange(statuses, state, activeAccount) } } } @@ -103,7 +104,7 @@ class CachedTimelineRemoteMediator( } db.withTransaction { - val overlappedStatuses = replaceStatusRange(statuses, state) + val overlappedStatuses = replaceStatusRange(statuses, state, activeAccount) /* In case we loaded a whole page and there was no overlap with existing statuses, we insert a placeholder because there might be even more unknown statuses */ @@ -111,7 +112,7 @@ class CachedTimelineRemoteMediator( /* This overrides the last of the newly loaded statuses with a placeholder to guarantee the placeholder has an id that exists on the server as not all servers handle client generated ids as expected */ - timelineDao.insertStatus( + timelineDao.insertHomeTimelineItem( Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id) ) } @@ -134,7 +135,8 @@ class CachedTimelineRemoteMediator( */ private suspend fun replaceStatusRange( statuses: List, - state: PagingState + state: PagingState, + activeAccount: AccountEntity ): Int { val overlappedStatuses = if (statuses.isNotEmpty()) { timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id) @@ -143,9 +145,9 @@ class CachedTimelineRemoteMediator( } for (status in statuses) { - timelineDao.insertAccount(status.account.toEntity(activeAccount.id, moshi)) - status.reblog?.account?.toEntity(activeAccount.id, moshi)?.let { rebloggedAccount -> - timelineDao.insertAccount(rebloggedAccount) + accountDao.insert(status.account.toEntity(activeAccount.id)) + status.reblog?.account?.toEntity(activeAccount.id)?.let { rebloggedAccount -> + accountDao.insert(rebloggedAccount) } // check if we already have one of the newly loaded statuses cached locally @@ -153,31 +155,35 @@ class CachedTimelineRemoteMediator( var oldStatus: TimelineStatusEntity? = null for (page in state.pages) { oldStatus = page.data.find { s -> - s.status.serverId == status.id + s.status?.serverId == status.actionableId }?.status if (oldStatus != null) break } - // The "expanded" property for Placeholders determines whether or not they are - // in the "loading" state, and should not be affected by the account's - // "alwaysOpenSpoiler" preference - val expanded = if (oldStatus?.isPlaceholder == true) { - oldStatus.expanded - } else { - oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler - } + val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler val contentShowing = oldStatus?.contentShowing ?: (activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive) - val contentCollapsed = oldStatus?.contentCollapsed ?: true + val contentCollapsed = oldStatus?.contentCollapsed != false - timelineDao.insertStatus( - status.toEntity( - timelineUserId = activeAccount.id, - moshi = moshi, + statusDao.insert( + status.actionableStatus.toEntity( + tuskyAccountId = activeAccount.id, expanded = expanded, contentShowing = contentShowing, contentCollapsed = contentCollapsed ) ) + timelineDao.insertHomeTimelineItem( + HomeTimelineEntity( + tuskyAccountId = activeAccount.id, + id = status.id, + statusId = status.actionableId, + reblogAccountId = if (status.reblog != null) { + status.account.id + } else { + null + } + ) + ) } return overlappedStatuses } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index 8177c2fa0..12ffafa7e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -38,20 +38,17 @@ import com.keylesspalace.tusky.components.timeline.toViewData import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.db.TimelineStatusWithAccount +import com.keylesspalace.tusky.db.entity.HomeTimelineData +import com.keylesspalace.tusky.db.entity.HomeTimelineEntity import com.keylesspalace.tusky.entity.Filter -import com.keylesspalace.tusky.entity.Poll -import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases -import com.keylesspalace.tusky.util.EmptyPagingSource import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData -import com.squareup.moshi.Moshi +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asExecutor import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn @@ -61,6 +58,7 @@ import retrofit2.HttpException /** * TimelineViewModel that caches all statuses in a local database */ +@HiltViewModel class CachedTimelineViewModel @Inject constructor( timelineCases: TimelineCases, private val api: MastodonApi, @@ -68,33 +66,28 @@ class CachedTimelineViewModel @Inject constructor( accountManager: AccountManager, sharedPreferences: SharedPreferences, filterModel: FilterModel, - private val db: AppDatabase, - private val moshi: Moshi + private val db: AppDatabase ) : TimelineViewModel( timelineCases, - api, eventHub, accountManager, sharedPreferences, filterModel ) { - private var currentPagingSource: PagingSource? = null + private var currentPagingSource: PagingSource? = null /** Map from status id to translation. */ private val translations = MutableStateFlow(mapOf()) @OptIn(ExperimentalPagingApi::class) override val statuses = Pager( - config = PagingConfig(pageSize = LOAD_AT_ONCE), - remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db, moshi), + config = PagingConfig( + pageSize = LOAD_AT_ONCE + ), + remoteMediator = CachedTimelineRemoteMediator(this, api, db), pagingSourceFactory = { - val activeAccount = accountManager.activeAccount - if (activeAccount == null) { - EmptyPagingSource() - } else { - db.timelineDao().getStatuses(activeAccount.id) - }.also { newPagingSource -> + db.timelineDao().getHomeTimeline(accountId).also { newPagingSource -> this.currentPagingSource = newPagingSource } } @@ -105,58 +98,42 @@ class CachedTimelineViewModel @Inject constructor( // adding another cachedIn() for the overall result. .cachedIn(viewModelScope) .combine(translations) { pagingData, translations -> - pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus -> - val translation = translations[timelineStatus.status.serverId] - timelineStatus.toViewData( - moshi, + pagingData.map { timelineData -> + val translation = translations[timelineData.status?.serverId] + timelineData.toViewData( isDetailed = false, translation = translation ) - }.filter(Dispatchers.Default.asExecutor()) { statusViewData -> + }.filter { statusViewData -> shouldFilterStatus(statusViewData) != Filter.Action.HIDE } } .flowOn(Dispatchers.Default) - override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) { - // handled by CacheUpdater - } - override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { - db.timelineDao().setExpanded(accountManager.activeAccount!!.id, status.id, expanded) + db.timelineStatusDao() + .setExpanded(accountId, status.actionableId, expanded) } } override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { - db.timelineDao() - .setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing) + db.timelineStatusDao() + .setContentShowing(accountId, status.actionableId, isShowing) } } override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { - db.timelineDao() - .setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed) - } - } - - override fun removeAllByAccountId(accountId: String) { - viewModelScope.launch { - db.timelineDao().removeAllByUser(accountManager.activeAccount!!.id, accountId) - } - } - - override fun removeAllByInstance(instance: String) { - viewModelScope.launch { - db.timelineDao().deleteAllFromInstance(accountManager.activeAccount!!.id, instance) + db.timelineStatusDao() + .setContentCollapsed(accountId, status.actionableId, isCollapsed) } } override fun clearWarning(status: StatusViewData.Concrete) { viewModelScope.launch { - db.timelineDao().clearWarning(accountManager.activeAccount!!.id, status.actionableId) + db.timelineStatusDao().clearWarning(accountId, status.actionableId) } } @@ -168,34 +145,32 @@ class CachedTimelineViewModel @Inject constructor( viewModelScope.launch { try { val timelineDao = db.timelineDao() + val statusDao = db.timelineStatusDao() + val accountDao = db.timelineAccountDao() - val activeAccount = accountManager.activeAccount!! - - timelineDao.insertStatus( - Placeholder(placeholderId, loading = true).toEntity( - activeAccount.id - ) + timelineDao.insertHomeTimelineItem( + Placeholder(placeholderId, loading = true).toEntity(tuskyAccountId = accountId) ) - val response = db.withTransaction { - val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId) - val idBelowPlaceholder = timelineDao.getIdBelow(activeAccount.id, placeholderId) - when (readingOrder) { - // Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately - // after minId and no larger than maxId - OLDEST_FIRST -> api.homeTimeline( - maxId = idAbovePlaceholder, - minId = idBelowPlaceholder, - limit = LOAD_AT_ONCE - ) - // Using sinceId, loads up to LOAD_AT_ONCE statuses immediately before - // maxId, and no smaller than minId. - NEWEST_FIRST -> api.homeTimeline( - maxId = idAbovePlaceholder, - sinceId = idBelowPlaceholder, - limit = LOAD_AT_ONCE - ) - } + val (idAbovePlaceholder, idBelowPlaceholder) = db.withTransaction { + timelineDao.getIdAbove(accountId, placeholderId) to + timelineDao.getIdBelow(accountId, placeholderId) + } + val response = when (readingOrder) { + // Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately + // after minId and no larger than maxId + OLDEST_FIRST -> api.homeTimeline( + maxId = idAbovePlaceholder, + minId = idBelowPlaceholder, + limit = LOAD_AT_ONCE + ) + // Using sinceId, loads up to LOAD_AT_ONCE statuses immediately before + // maxId, and no smaller than minId. + NEWEST_FIRST -> api.homeTimeline( + maxId = idAbovePlaceholder, + sinceId = idBelowPlaceholder, + limit = LOAD_AT_ONCE + ) } val statuses = response.body() @@ -204,12 +179,17 @@ class CachedTimelineViewModel @Inject constructor( return@launch } + val account = activeAccountFlow.value + if (account == null) { + return@launch + } + db.withTransaction { - timelineDao.delete(activeAccount.id, placeholderId) + timelineDao.deleteHomeTimelineItem(accountId, placeholderId) val overlappedStatuses = if (statuses.isNotEmpty()) { timelineDao.deleteRange( - activeAccount.id, + accountId, statuses.last().id, statuses.first().id ) @@ -218,20 +198,31 @@ class CachedTimelineViewModel @Inject constructor( } for (status in statuses) { - timelineDao.insertAccount(status.account.toEntity(activeAccount.id, moshi)) - status.reblog?.account?.toEntity(activeAccount.id, moshi) + accountDao.insert(status.account.toEntity(accountId)) + status.reblog?.account?.toEntity(accountId) ?.let { rebloggedAccount -> - timelineDao.insertAccount(rebloggedAccount) + accountDao.insert(rebloggedAccount) } - timelineDao.insertStatus( - status.toEntity( - timelineUserId = activeAccount.id, - moshi = moshi, - expanded = activeAccount.alwaysOpenSpoiler, - contentShowing = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, + statusDao.insert( + status.actionableStatus.toEntity( + tuskyAccountId = accountId, + expanded = account.alwaysOpenSpoiler, + contentShowing = account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, contentCollapsed = true ) ) + timelineDao.insertHomeTimelineItem( + HomeTimelineEntity( + tuskyAccountId = accountId, + id = status.id, + statusId = status.actionableId, + reblogAccountId = if (status.reblog != null) { + status.account.id + } else { + null + } + ) + ) } /* In case we loaded a whole page and there was no overlap with existing statuses, @@ -244,11 +235,11 @@ class CachedTimelineViewModel @Inject constructor( OLDEST_FIRST -> statuses.first().id NEWEST_FIRST -> statuses.last().id } - timelineDao.insertStatus( + timelineDao.insertHomeTimelineItem( Placeholder( idToConvert, loading = false - ).toEntity(activeAccount.id) + ).toEntity(accountId) ) } } @@ -261,52 +252,50 @@ class CachedTimelineViewModel @Inject constructor( } private suspend fun loadMoreFailed(placeholderId: String, e: Exception) { - Log.w("CachedTimelineVM", "failed loading statuses", e) + Log.w(TAG, "failed loading statuses", e) val activeAccount = accountManager.activeAccount!! db.timelineDao() - .insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id)) - } - - override fun handleStatusChangedEvent(status: Status) { - // handled by CacheUpdater + .insertHomeTimelineItem(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id)) } override fun fullReload() { viewModelScope.launch { val activeAccount = accountManager.activeAccount!! - db.timelineDao().removeAll(activeAccount.id) + db.timelineDao().removeAllHomeTimelineItems(activeAccount.id) } } override fun saveReadingPosition(statusId: String) { - accountManager.activeAccount?.let { account -> - Log.d(TAG, "Saving position at: $statusId") - account.lastVisibleHomeTimelineStatusId = statusId - accountManager.saveAccount(account) + viewModelScope.launch { + accountManager.activeAccount?.let { account -> + Log.d(TAG, "Saving position at: $statusId") + accountManager.updateAccount(account) { + copy(lastVisibleHomeTimelineStatusId = statusId) + } + } } } override suspend fun invalidate() { // invalidating when we don't have statuses yet can cause empty timelines because it cancels the network load - if (db.timelineDao().getStatusCount(accountManager.activeAccount!!.id) > 0) { + if (db.timelineDao().getHomeTimelineItemCount(accountId) > 0) { currentPagingSource?.invalidate() } } override suspend fun translate(status: StatusViewData.Concrete): NetworkResult { - translations.value = translations.value + (status.id to TranslationViewData.Loading) + translations.value += (status.id to TranslationViewData.Loading) return timelineCases.translate(status.actionableId) .map { translation -> - translations.value = - translations.value + (status.id to TranslationViewData.Loaded(translation)) + translations.value += (status.actionableId to TranslationViewData.Loaded(translation)) } .onFailure { - translations.value = translations.value - status.id + translations.value -= status.actionableId } } override fun untranslate(status: StatusViewData.Concrete) { - translations.value = translations.value - status.id + translations.value -= status.actionableId } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index f19b2240f..566274afb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -21,7 +21,6 @@ import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import com.keylesspalace.tusky.components.timeline.util.ifExpected -import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.util.HttpHeaderLink import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData @@ -29,7 +28,6 @@ import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) class NetworkTimelineRemoteMediator( - private val accountManager: AccountManager, private val viewModel: NetworkTimelineViewModel ) : RemoteMediator() { @@ -68,7 +66,7 @@ class NetworkTimelineRemoteMediator( return MediatorResult.Error(HttpException(statusResponse)) } - val activeAccount = accountManager.activeAccount!! + val activeAccount = viewModel.activeAccountFlow.value!! val data = statuses.map { status -> @@ -78,7 +76,7 @@ class NetworkTimelineRemoteMediator( val contentShowing = oldStatus?.isShowingContent ?: (activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive) val expanded = oldStatus?.isExpanded ?: activeAccount.alwaysOpenSpoiler - val contentCollapsed = oldStatus?.isCollapsed ?: true + val contentCollapsed = oldStatus?.isCollapsed != false status.toViewData( isShowingContent = contentShowing, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index 60eed28e0..c5f81778e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -26,7 +26,15 @@ import androidx.paging.filter import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.map import at.connyduck.calladapter.networkresult.onFailure +import com.keylesspalace.tusky.appstore.BlockEvent +import com.keylesspalace.tusky.appstore.DomainMuteEvent +import com.keylesspalace.tusky.appstore.Event import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.MuteEvent +import com.keylesspalace.tusky.appstore.PollVoteEvent +import com.keylesspalace.tusky.appstore.StatusChangedEvent +import com.keylesspalace.tusky.appstore.StatusDeletedEvent +import com.keylesspalace.tusky.appstore.UnfollowEvent import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter @@ -41,6 +49,7 @@ import com.keylesspalace.tusky.util.isLessThanOrEqual import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData +import dagger.hilt.android.lifecycle.HiltViewModel import java.io.IOException import javax.inject.Inject import kotlinx.coroutines.Dispatchers @@ -54,6 +63,7 @@ import retrofit2.Response /** * TimelineViewModel that caches all statuses in an in-memory list */ +@HiltViewModel class NetworkTimelineViewModel @Inject constructor( timelineCases: TimelineCases, private val api: MastodonApi, @@ -63,7 +73,6 @@ class NetworkTimelineViewModel @Inject constructor( filterModel: FilterModel ) : TimelineViewModel( timelineCases, - api, eventHub, accountManager, sharedPreferences, @@ -78,7 +87,9 @@ class NetworkTimelineViewModel @Inject constructor( @OptIn(ExperimentalPagingApi::class) override val statuses = Pager( - config = PagingConfig(pageSize = LOAD_AT_ONCE), + config = PagingConfig( + pageSize = LOAD_AT_ONCE + ), pagingSourceFactory = { NetworkTimelinePagingSource( viewModel = this @@ -86,7 +97,7 @@ class NetworkTimelineViewModel @Inject constructor( currentSource = source } }, - remoteMediator = NetworkTimelineRemoteMediator(accountManager, this) + remoteMediator = NetworkTimelineRemoteMediator(this) ).flow .map { pagingData -> pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData -> @@ -96,10 +107,47 @@ class NetworkTimelineViewModel @Inject constructor( .flowOn(Dispatchers.Default) .cachedIn(viewModelScope) - override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) { - status.copy( - status = status.status.copy(poll = newPoll) - ).update() + init { + viewModelScope.launch { + eventHub.events + .collect { event -> handleEvent(event) } + } + } + + private fun handleEvent(event: Event) { + when (event) { + is StatusChangedEvent -> handleStatusChangedEvent(event.status) + is PollVoteEvent -> handlePollVote(event.statusId, event.poll) + is UnfollowEvent -> { + if (kind == Kind.HOME) { + val id = event.accountId + removeAllByAccountId(id) + } + } + is BlockEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val id = event.accountId + removeAllByAccountId(id) + } + } + is MuteEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val id = event.accountId + removeAllByAccountId(id) + } + } + is DomainMuteEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val instance = event.instance + removeAllByInstance(instance) + } + } + is StatusDeletedEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + removeStatusWithId(event.statusId) + } + } + } } override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { @@ -120,7 +168,7 @@ class NetworkTimelineViewModel @Inject constructor( ).update() } - override fun removeAllByAccountId(accountId: String) { + private fun removeAllByAccountId(accountId: String) { statusData.removeAll { vd -> val status = vd.asStatusOrNull()?.status ?: return@removeAll false status.account.id == accountId || status.actionableStatus.account.id == accountId @@ -128,7 +176,7 @@ class NetworkTimelineViewModel @Inject constructor( currentSource?.invalidate() } - override fun removeAllByInstance(instance: String) { + private fun removeAllByInstance(instance: String) { statusData.removeAll { vd -> val status = vd.asStatusOrNull()?.status ?: return@removeAll false getDomain(status.account.url) == instance @@ -241,13 +289,13 @@ class NetworkTimelineViewModel @Inject constructor( currentSource?.invalidate() } - override fun handleStatusChangedEvent(status: Status) { - updateStatusById(status.id) { oldViewData -> - status.toViewData( - isShowingContent = oldViewData.isShowingContent, - isExpanded = oldViewData.isExpanded, - isCollapsed = oldViewData.isCollapsed - ) + private fun handleStatusChangedEvent(status: Status) { + updateStatusByActionableId(status.id) { status } + } + + private fun handlePollVote(statusId: String, poll: Poll) { + updateStatusByActionableId(statusId) { status -> + status.copy(poll = poll) } } @@ -258,7 +306,7 @@ class NetworkTimelineViewModel @Inject constructor( } override fun clearWarning(status: StatusViewData.Concrete) { - updateActionableStatusById(status.id) { + updateStatusByActionableId(status.actionableId) { it.copy(filtered = emptyList()) } } @@ -346,23 +394,17 @@ class NetworkTimelineViewModel @Inject constructor( currentSource?.invalidate() } - private inline fun updateStatusById( - id: String, - updater: (StatusViewData.Concrete) -> StatusViewData.Concrete - ) { - val pos = statusData.indexOfFirst { it.asStatusOrNull()?.id == id } - if (pos == -1) return - updateViewDataAt(pos, updater) - } - - private inline fun updateActionableStatusById(id: String, updater: (Status) -> Status) { - val pos = statusData.indexOfFirst { it.asStatusOrNull()?.id == id } - if (pos == -1) return - updateViewDataAt(pos) { vd -> - if (vd.status.reblog != null) { - vd.copy(status = vd.status.copy(reblog = updater(vd.status.reblog))) - } else { - vd.copy(status = updater(vd.status)) + private inline fun updateStatusByActionableId(id: String, updater: (Status) -> Status) { + // posts can be multiple times in the timeline, e.g. once the original and once as boost + statusData.forEachIndexed { index, status -> + if (status.asStatusOrNull()?.actionableId == id) { + updateViewDataAt(index) { vd -> + if (vd.status.reblog != null) { + vd.copy(status = vd.status.copy(reblog = updater(vd.status.reblog))) + } else { + vd.copy(status = updater(vd.status)) + } + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 3d02b90dc..4de9f88ca 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -21,32 +21,18 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import at.connyduck.calladapter.networkresult.NetworkResult -import at.connyduck.calladapter.networkresult.fold -import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrThrow -import com.keylesspalace.tusky.appstore.BlockEvent -import com.keylesspalace.tusky.appstore.DomainMuteEvent -import com.keylesspalace.tusky.appstore.Event import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.FilterUpdatedEvent -import com.keylesspalace.tusky.appstore.MuteConversationEvent -import com.keylesspalace.tusky.appstore.MuteEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent -import com.keylesspalace.tusky.appstore.StatusChangedEvent -import com.keylesspalace.tusky.appstore.StatusDeletedEvent -import com.keylesspalace.tusky.appstore.UnfollowEvent import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter -import com.keylesspalace.tusky.entity.FilterV1 -import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel -import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.TimelineCases -import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @@ -54,13 +40,15 @@ import kotlinx.coroutines.launch abstract class TimelineViewModel( protected val timelineCases: TimelineCases, - private val api: MastodonApi, private val eventHub: EventHub, - protected val accountManager: AccountManager, + val accountManager: AccountManager, private val sharedPreferences: SharedPreferences, private val filterModel: FilterModel ) : ViewModel() { + val activeAccountFlow = accountManager.activeAccount(viewModelScope) + protected val accountId: Long = activeAccountFlow.value!!.id + abstract val statuses: Flow> var kind: Kind = Kind.HOME @@ -81,33 +69,48 @@ abstract class TimelineViewModel( this.kind = kind this.id = id this.tags = tags - filterModel.kind = kind.toFilterKind() + + val activeAccount = activeAccountFlow.value!! if (kind == Kind.HOME) { // Note the variable is "true if filter" but the underlying preference/settings text is "true if show" - filterRemoveReplies = - !(accountManager.activeAccount?.isShowHomeReplies ?: true) - filterRemoveReblogs = - !(accountManager.activeAccount?.isShowHomeBoosts ?: true) - filterRemoveSelfReblogs = - !(accountManager.activeAccount?.isShowHomeSelfBoosts ?: true) + filterRemoveReplies = !activeAccount.isShowHomeReplies + filterRemoveReblogs = !activeAccount.isShowHomeBoosts + filterRemoveSelfReblogs = !activeAccount.isShowHomeSelfBoosts } readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)) - this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia - this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler + this.alwaysShowSensitiveMedia = activeAccount.alwaysShowSensitiveMedia + this.alwaysOpenSpoilers = activeAccount.alwaysOpenSpoiler viewModelScope.launch { eventHub.events - .collect { event -> handleEvent(event) } + .collect { event -> + when (event) { + is PreferenceChangedEvent -> { + onPreferenceChanged(event.preferenceKey) + } + is FilterUpdatedEvent -> { + if (filterContextMatchesKind(this@TimelineViewModel.kind, event.filterContext)) { + filterModel.init(kind.toFilterKind()) + invalidate() + } + } + } + } } - reloadFilters() + viewModelScope.launch { + val needsRefresh = filterModel.init(kind.toFilterKind()) + if (needsRefresh) { + fullReload() + } + } } - fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { + fun reblog(reblog: Boolean, status: StatusViewData.Concrete, visibility: Status.Visibility = Status.Visibility.PUBLIC): Job = viewModelScope.launch { try { - timelineCases.reblog(status.actionableId, reblog).getOrThrow() + timelineCases.reblog(status.actionableId, reblog, visibility).getOrThrow() } catch (t: Exception) { ifExpected(t) { Log.d(TAG, "Failed to reblog status " + status.actionableId, t) @@ -142,9 +145,6 @@ abstract class TimelineViewModel( return@launch } - val votedPoll = poll.votedCopy(choices) - updatePoll(votedPoll, status) - try { timelineCases.voteInPoll(status.actionableId, poll.id, choices).getOrThrow() } catch (t: Exception) { @@ -154,24 +154,16 @@ abstract class TimelineViewModel( } } - abstract fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) - abstract fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) abstract fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) abstract fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) - abstract fun removeAllByAccountId(accountId: String) - - abstract fun removeAllByInstance(instance: String) - abstract fun removeStatusWithId(id: String) abstract fun loadMore(placeholderId: String) - abstract fun handleStatusChangedEvent(status: Status) - abstract fun fullReload() abstract fun clearWarning(status: StatusViewData.Concrete) @@ -185,11 +177,14 @@ abstract class TimelineViewModel( protected fun shouldFilterStatus(statusViewData: StatusViewData): Filter.Action { val status = statusViewData.asStatusOrNull()?.status ?: return Filter.Action.NONE return if ( - (status.inReplyToId != null && filterRemoveReplies) || + (status.isReply && filterRemoveReplies) || (status.reblog != null && filterRemoveReblogs) || - ((status.account.id == status.reblog?.account?.id) && filterRemoveSelfReblogs) + (status.account.id == status.reblog?.account?.id && filterRemoveSelfReblogs) ) { - return Filter.Action.HIDE + Filter.Action.HIDE + } else if (status.actionableStatus.account.id == activeAccountFlow.value?.accountId) { + // Mastodon filters don't apply for own posts + Filter.Action.NONE } else { statusViewData.filterAction = filterModel.shouldFilterStatus(status.actionableStatus) statusViewData.filterAction @@ -197,119 +192,44 @@ abstract class TimelineViewModel( } private fun onPreferenceChanged(key: String) { - when (key) { - PrefKeys.TAB_FILTER_HOME_REPLIES -> { - val filter = accountManager.activeAccount?.isShowHomeReplies ?: true - val oldRemoveReplies = filterRemoveReplies - filterRemoveReplies = kind == Kind.HOME && !filter - if (oldRemoveReplies != filterRemoveReplies) { - fullReload() - } - } - PrefKeys.TAB_FILTER_HOME_BOOSTS -> { - val filter = accountManager.activeAccount?.isShowHomeBoosts ?: true - val oldRemoveReblogs = filterRemoveReblogs - filterRemoveReblogs = kind == Kind.HOME && !filter - if (oldRemoveReblogs != filterRemoveReblogs) { - fullReload() - } - } - PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> { - val filter = accountManager.activeAccount?.isShowHomeSelfBoosts ?: true - val oldRemoveSelfReblogs = filterRemoveSelfReblogs - filterRemoveSelfReblogs = kind == Kind.HOME && !filter - if (oldRemoveSelfReblogs != filterRemoveSelfReblogs) { - fullReload() - } - } - FilterV1.HOME, FilterV1.NOTIFICATIONS, FilterV1.THREAD, FilterV1.PUBLIC, FilterV1.ACCOUNT -> { - if (filterContextMatchesKind(kind, listOf(key))) { - reloadFilters() - } - } - PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> { - // it is ok if only newly loaded statuses are affected, no need to fully refresh - alwaysShowSensitiveMedia = - accountManager.activeAccount!!.alwaysShowSensitiveMedia - } - PrefKeys.READING_ORDER -> { - readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)) - } - } - } - - private fun handleEvent(event: Event) { - when (event) { - is StatusChangedEvent -> handleStatusChangedEvent(event.status) - is MuteConversationEvent -> fullReload() - is UnfollowEvent -> { - if (kind == Kind.HOME) { - val id = event.accountId - removeAllByAccountId(id) - } - } - is BlockEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - val id = event.accountId - removeAllByAccountId(id) - } - } - is MuteEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - val id = event.accountId - removeAllByAccountId(id) - } - } - is DomainMuteEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - val instance = event.instance - removeAllByInstance(instance) - } - } - is StatusDeletedEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - removeStatusWithId(event.statusId) - } - } - is PreferenceChangedEvent -> { - onPreferenceChanged(event.preferenceKey) - } - is FilterUpdatedEvent -> { - if (filterContextMatchesKind(kind, event.filterContext)) { - fullReload() - } - } - } - } - - private fun reloadFilters() { - viewModelScope.launch { - api.getFilters().fold( - { - // After the filters are loaded we need to reload displayed content to apply them. - // It can happen during the usage or at startup, when we get statuses before filters. - invalidate() - }, - { throwable -> - if (throwable.isHttpNotFound()) { - // Fallback to client-side filter code - val filters = api.getFiltersV1().getOrElse { - Log.e(TAG, "Failed to fetch filters", it) - return@launch - } - filterModel.initWithFilters( - filters.filter { - filterContextMatchesKind(kind, it.context) - } - ) - // After the filters are loaded we need to reload displayed content to apply them. - // It can happen during the usage or at startup, when we get statuses before filters. - invalidate() - } else { - Log.e(TAG, "Error getting filters", throwable) + activeAccountFlow.value?.let { activeAccount -> + when (key) { + PrefKeys.TAB_FILTER_HOME_REPLIES -> { + val filter = !activeAccount.isShowHomeReplies + val oldRemoveReplies = filterRemoveReplies + filterRemoveReplies = kind == Kind.HOME && !filter + if (oldRemoveReplies != filterRemoveReplies) { + fullReload() } } - ) + + PrefKeys.TAB_FILTER_HOME_BOOSTS -> { + val filter = !activeAccount.isShowHomeBoosts + val oldRemoveReblogs = filterRemoveReblogs + filterRemoveReblogs = kind == Kind.HOME && !filter + if (oldRemoveReblogs != filterRemoveReblogs) { + fullReload() + } + } + + PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> { + val filter = !activeAccount.isShowHomeSelfBoosts + val oldRemoveSelfReblogs = filterRemoveSelfReblogs + filterRemoveSelfReblogs = kind == Kind.HOME && !filter + if (oldRemoveSelfReblogs != filterRemoveSelfReblogs) { + fullReload() + } + } + + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> { + // it is ok if only newly loaded statuses are affected, no need to fully refresh + alwaysShowSensitiveMedia = activeAccount.alwaysShowSensitiveMedia + } + + PrefKeys.READING_ORDER -> { + readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)) + } + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt index bdf2cdc79..6e205a21f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt @@ -23,14 +23,10 @@ import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ActivityTrendingBinding import com.keylesspalace.tusky.util.viewBinding -import dagger.android.DispatchingAndroidInjector -import dagger.android.HasAndroidInjector -import javax.inject.Inject +import dagger.hilt.android.AndroidEntryPoint -class TrendingActivity : BaseActivity(), HasAndroidInjector { - - @Inject - lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector +@AndroidEntryPoint +class TrendingActivity : BaseActivity() { private val binding: ActivityTrendingBinding by viewBinding(ActivityTrendingBinding::inflate) @@ -54,8 +50,6 @@ class TrendingActivity : BaseActivity(), HasAndroidInjector { } } - override fun androidInjector() = dispatchingAndroidInjector - companion object { fun getIntent(context: Context) = Intent(context, TrendingActivity::class.java) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagViewHolder.kt index ac1e8c72d..318683ba1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagViewHolder.kt @@ -20,13 +20,16 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding import com.keylesspalace.tusky.util.formatNumber import com.keylesspalace.tusky.viewdata.TrendingViewData +import java.text.NumberFormat class TrendingTagViewHolder( private val binding: ItemTrendingCellBinding ) : RecyclerView.ViewHolder(binding.root) { + private val numberFormat: NumberFormat = NumberFormat.getNumberInstance() + fun setup(tagViewData: TrendingViewData.Tag, onViewTag: (String) -> Unit) { - binding.tag.text = binding.root.context.getString(R.string.title_tag, tagViewData.name) + binding.tag.text = binding.root.context.getString(R.string.hashtag_format, tagViewData.name) binding.graph.maxTrendingValue = tagViewData.maxTrendingValue binding.graph.primaryLineData = tagViewData.usage @@ -37,8 +40,8 @@ class TrendingTagViewHolder( val totalAccounts = tagViewData.accounts.sum() binding.totalAccounts.text = formatNumber(totalAccounts, 1000) - binding.currentUsage.text = tagViewData.usage.last().toString() - binding.currentAccounts.text = tagViewData.usage.last().toString() + binding.currentUsage.text = numberFormat.format(tagViewData.usage.last()) + binding.currentAccounts.text = numberFormat.format(tagViewData.accounts.last()) itemView.setOnClickListener { onViewTag(tagViewData.name) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsFragment.kt index 782231c2d..6903bfe64 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsFragment.kt @@ -20,12 +20,13 @@ import android.os.Bundle import android.util.Log import android.view.View import android.view.accessibility.AccessibilityManager -import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener @@ -34,50 +35,51 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.components.trending.viewmodel.TrendingTagsViewModel import com.keylesspalace.tusky.databinding.FragmentTrendingTagsBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment +import com.keylesspalace.tusky.util.ensureBottomPadding import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.TrendingViewData -import javax.inject.Inject +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +@AndroidEntryPoint class TrendingTagsFragment : Fragment(R.layout.fragment_trending_tags), OnRefreshListener, - Injectable, ReselectableFragment, RefreshableFragment { - @Inject - lateinit var viewModelFactory: ViewModelFactory - - private val viewModel: TrendingTagsViewModel by viewModels { viewModelFactory } + private val viewModel: TrendingTagsViewModel by viewModels() private val binding by viewBinding(FragmentTrendingTagsBinding::bind) - private val adapter = TrendingTagsAdapter(::onViewTag) + private var adapter: TrendingTagsAdapter? = null override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) val columnCount = requireContext().resources.getInteger(R.integer.trending_column_count) - setupLayoutManager(columnCount) + adapter?.let { + setupLayoutManager(it, columnCount) + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - setupSwipeRefreshLayout() - setupRecyclerView() + val adapter = TrendingTagsAdapter(::onViewTag) + this.adapter = adapter + binding.swipeRefreshLayout.setOnRefreshListener(this) + setupRecyclerView(adapter) adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - if (positionStart == 0 && adapter.itemCount != itemCount) { + val firstPos = (binding.recyclerView.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() + if (firstPos == 0 && positionStart == 0 && adapter.itemCount != itemCount) { binding.recyclerView.post { if (getView() != null) { binding.recyclerView.scrollBy( @@ -92,21 +94,18 @@ class TrendingTagsFragment : viewLifecycleOwner.lifecycleScope.launch { viewModel.uiState.collectLatest { trendingState -> - processViewState(trendingState) + processViewState(adapter, trendingState) } } - - if (activity is ActionButtonActivity) { - (activity as ActionButtonActivity).actionButton?.visibility = View.GONE - } } - private fun setupSwipeRefreshLayout() { - binding.swipeRefreshLayout.setOnRefreshListener(this) - binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) + override fun onDestroyView() { + // Clear the adapter to prevent leaking the View + adapter = null + super.onDestroyView() } - private fun setupLayoutManager(columnCount: Int) { + private fun setupLayoutManager(adapter: TrendingTagsAdapter, columnCount: Int) { binding.recyclerView.layoutManager = GridLayoutManager(context, columnCount).apply { spanSizeLookup = object : SpanSizeLookup() { override fun getSpanSize(position: Int): Int { @@ -120,10 +119,12 @@ class TrendingTagsFragment : } } - private fun setupRecyclerView() { + private fun setupRecyclerView(adapter: TrendingTagsAdapter) { + binding.recyclerView.ensureBottomPadding(fab = actionButtonPresent()) + val columnCount = requireContext().resources.getInteger(R.integer.trending_column_count) - setupLayoutManager(columnCount) + setupLayoutManager(adapter, columnCount) binding.recyclerView.setHasFixedSize(true) @@ -141,19 +142,22 @@ class TrendingTagsFragment : ) } - private fun processViewState(uiState: TrendingTagsViewModel.TrendingTagsUiState) { + private fun processViewState( + adapter: TrendingTagsAdapter, + uiState: TrendingTagsViewModel.TrendingTagsUiState + ) { Log.d(TAG, uiState.loadingState.name) when (uiState.loadingState) { TrendingTagsViewModel.LoadingState.INITIAL -> clearLoadingState() TrendingTagsViewModel.LoadingState.LOADING -> applyLoadingState() TrendingTagsViewModel.LoadingState.REFRESHING -> applyRefreshingState() - TrendingTagsViewModel.LoadingState.LOADED -> applyLoadedState(uiState.trendingViewData) + TrendingTagsViewModel.LoadingState.LOADED -> applyLoadedState(adapter, uiState.trendingViewData) TrendingTagsViewModel.LoadingState.ERROR_NETWORK -> networkError() TrendingTagsViewModel.LoadingState.ERROR_OTHER -> otherError() } } - private fun applyLoadedState(viewData: List) { + private fun applyLoadedState(adapter: TrendingTagsAdapter, viewData: List) { clearLoadingState() adapter.submitList(viewData) @@ -214,31 +218,26 @@ class TrendingTagsFragment : } private fun actionButtonPresent(): Boolean { - return activity is ActionButtonActivity + return (activity as? ActionButtonActivity?)?.actionButton != null } private var talkBackWasEnabled = false override fun onResume() { super.onResume() - val a11yManager = - ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java) + val a11yManager = requireContext().getSystemService() val wasEnabled = talkBackWasEnabled talkBackWasEnabled = a11yManager?.isEnabled == true Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled") if (talkBackWasEnabled && !wasEnabled) { + val adapter = requireNotNull(this.adapter) adapter.notifyItemRangeChanged(0, adapter.itemCount) } - - if (actionButtonPresent()) { - val composeButton = (activity as ActionButtonActivity).actionButton - composeButton?.hide() - } } override fun onReselect() { - if (isAdded) { + if (view != null) { binding.recyclerView.layoutManager?.scrollToPosition(0) binding.recyclerView.stopScroll() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt index 3237d8ead..ea8b4b092 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt @@ -27,6 +27,7 @@ import com.keylesspalace.tusky.entity.start import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.TrendingViewData +import dagger.hilt.android.lifecycle.HiltViewModel import java.io.IOException import javax.inject.Inject import kotlinx.coroutines.async @@ -35,6 +36,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch +@HiltViewModel class TrendingTagsViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt index 0c1c7fc5b..0b5f7d458 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt @@ -19,10 +19,13 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.FilteredStatusViewHolder import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.adapter.StatusDetailedViewHolder import com.keylesspalace.tusky.adapter.StatusViewHolder +import com.keylesspalace.tusky.databinding.ItemStatusFilteredBinding import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions @@ -31,29 +34,37 @@ import com.keylesspalace.tusky.viewdata.StatusViewData class ThreadAdapter( private val statusDisplayOptions: StatusDisplayOptions, private val statusActionListener: StatusActionListener -) : ListAdapter(ThreadDifferCallback) { +) : ListAdapter(ThreadDifferCallback) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusBaseViewHolder { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(parent.context) return when (viewType) { - VIEW_TYPE_STATUS -> { + VIEW_TYPE_STATUS -> StatusViewHolder(inflater.inflate(R.layout.item_status, parent, false)) - } - VIEW_TYPE_STATUS_FILTERED -> { - StatusViewHolder(inflater.inflate(R.layout.item_status_wrapper, parent, false)) - } - VIEW_TYPE_STATUS_DETAILED -> { + VIEW_TYPE_STATUS_FILTERED -> + FilteredStatusViewHolder( + ItemStatusFilteredBinding.inflate(inflater, parent, false), + statusActionListener + ) + VIEW_TYPE_STATUS_DETAILED -> StatusDetailedViewHolder( inflater.inflate(R.layout.item_status_detailed, parent, false) ) - } else -> error("Unknown item type: $viewType") } } - override fun onBindViewHolder(viewHolder: StatusBaseViewHolder, position: Int) { + override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) { + onBindViewHolder(viewHolder, position, emptyList()) + } + + override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int, payloads: List) { val status = getItem(position) - viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions) + if (viewHolder is FilteredStatusViewHolder) { + viewHolder.bind(status) + } else if (viewHolder is StatusBaseViewHolder) { + viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions, payloads, false) + } } override fun getItemViewType(position: Int): Int { @@ -68,7 +79,6 @@ class ThreadAdapter( } companion object { - private const val TAG = "ThreadAdapter" private const val VIEW_TYPE_STATUS = 0 private const val VIEW_TYPE_STATUS_DETAILED = 1 private const val VIEW_TYPE_STATUS_FILTERED = 2 @@ -94,7 +104,7 @@ class ThreadAdapter( ): Any? { return if (oldItem == newItem) { // If items are equal - update timestamp only - listOf(StatusBaseViewHolder.Key.KEY_CREATED) + StatusBaseViewHolder.Key.KEY_CREATED } else { // If items are different - update the whole view holder null diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadActivity.kt index 70c0df191..83888d02a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadActivity.kt @@ -23,26 +23,25 @@ import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ActivityViewThreadBinding import com.keylesspalace.tusky.util.viewBinding -import dagger.android.DispatchingAndroidInjector -import dagger.android.HasAndroidInjector -import javax.inject.Inject +import dagger.hilt.android.AndroidEntryPoint -class ViewThreadActivity : BottomSheetActivity(), HasAndroidInjector { +@AndroidEntryPoint +class ViewThreadActivity : BottomSheetActivity() { private val binding by viewBinding(ActivityViewThreadBinding::inflate) - @Inject - lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) - setSupportActionBar(binding.toolbar) + setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.run { setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) setDisplayShowTitleEnabled(true) } + + setTitle(R.string.title_view_thread) + val id = intent.getStringExtra(ID_EXTRA)!! val url = intent.getStringExtra(URL_EXTRA)!! val fragment = @@ -54,8 +53,6 @@ class ViewThreadActivity : BottomSheetActivity(), HasAndroidInjector { } } - override fun androidInjector() = dispatchingAndroidInjector - companion object { fun startIntent(context: Context, id: String, url: String): Intent { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt index 87aebe17f..3d439d248 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -15,14 +15,13 @@ package com.keylesspalace.tusky.components.viewthread +import android.content.SharedPreferences import android.os.Bundle import android.util.Log -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 android.widget.LinearLayout import androidx.annotation.CheckResult import androidx.core.view.MenuProvider @@ -30,7 +29,6 @@ import androidx.fragment.app.commit import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator @@ -42,8 +40,8 @@ import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Companion.newIntent import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.db.DraftsAlert +import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.fragment.SFragment import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.settings.PrefKeys @@ -54,31 +52,36 @@ import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation +import com.keylesspalace.tusky.util.updateRelativeTimePeriodically import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData +import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.launch +@AndroidEntryPoint class ViewThreadFragment : - SFragment(), + SFragment(R.layout.fragment_view_thread), OnRefreshListener, StatusActionListener, - MenuProvider, - Injectable { + MenuProvider { @Inject - lateinit var viewModelFactory: ViewModelFactory + lateinit var preferences: SharedPreferences - private val viewModel: ViewThreadViewModel by viewModels { viewModelFactory } + @Inject + lateinit var draftsAlert: DraftsAlert + + private val viewModel: ViewThreadViewModel by viewModels() private val binding by viewBinding(FragmentViewThreadBinding::bind) - private lateinit var adapter: ThreadAdapter + private var adapter: ThreadAdapter? = null private lateinit var thisThreadsStatusId: String private var alwaysShowSensitiveMedia = false @@ -97,8 +100,9 @@ class ViewThreadFragment : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) thisThreadsStatusId = requireArguments().getString(ID_EXTRA)!! - val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + } + private fun createAdapter(): ThreadAdapter { val statusDisplayOptions = StatusDisplayOptions( animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, @@ -118,22 +122,16 @@ class ViewThreadFragment : showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) - adapter = ThreadAdapter(statusDisplayOptions, this) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_view_thread, container, false) + return ThreadAdapter(statusDisplayOptions, this) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + val adapter = createAdapter() + this.adapter = adapter + binding.swipeRefreshLayout.setOnRefreshListener(this) - binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) binding.recyclerView.setHasFixedSize(true) binding.recyclerView.layoutManager = LinearLayoutManager(context) @@ -255,9 +253,19 @@ class ViewThreadFragment : } } + updateRelativeTimePeriodically(preferences, adapter) + + draftsAlert.observeInContext(requireActivity(), true) + viewModel.loadThread(thisThreadsStatusId) } + override fun onDestroyView() { + // Clear the adapter to prevent leaking the View + adapter = null + super.onDestroyView() + } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.fragment_view_thread, menu) val actionReveal = menu.findItem(R.id.action_reveal) @@ -291,11 +299,6 @@ class ViewThreadFragment : } } - override fun onResume() { - super.onResume() - requireActivity().title = getString(R.string.title_view_thread) - } - /** * Create a job to implement a delayed-visible progress bar. * @@ -324,12 +327,13 @@ class ViewThreadFragment : } override fun onReply(position: Int) { - super.reply(adapter.currentList[position].status) + val viewData = adapter?.currentList?.getOrNull(position) ?: return + super.reply(viewData.status) } - override fun onReblog(reblog: Boolean, position: Int) { - val status = adapter.currentList[position] - viewModel.reblog(reblog, status) + override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) { + val status = adapter?.currentList?.getOrNull(position) ?: return + viewModel.reblog(reblog, status, visibility) } override val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit) = @@ -344,8 +348,8 @@ class ViewThreadFragment : } private fun onTranslate(position: Int) { - val status = adapter.currentList[position] - lifecycleScope.launch { + val status = adapter?.currentList?.getOrNull(position) ?: return + viewLifecycleOwner.lifecycleScope.launch { viewModel.translate(status) .onFailure { Snackbar.make( @@ -358,22 +362,22 @@ class ViewThreadFragment : } override fun onUntranslate(position: Int) { - val status = adapter.currentList[position] + val status = adapter?.currentList?.getOrNull(position) ?: return viewModel.untranslate(status) } override fun onFavourite(favourite: Boolean, position: Int) { - val status = adapter.currentList[position] + val status = adapter?.currentList?.getOrNull(position) ?: return viewModel.favorite(favourite, status) } override fun onBookmark(bookmark: Boolean, position: Int) { - val status = adapter.currentList[position] + val status = adapter?.currentList?.getOrNull(position) ?: return viewModel.bookmark(bookmark, status) } override fun onMore(view: View, position: Int) { - val viewData = adapter.currentList[position] + val viewData = adapter?.currentList?.getOrNull(position) ?: return super.more( viewData.status, view, @@ -383,7 +387,7 @@ class ViewThreadFragment : } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - val status = adapter.currentList[position].status + val status = adapter?.currentList?.getOrNull(position) ?: return super.viewMedia( attachmentIndex, list(status, alwaysShowSensitiveMedia), @@ -392,7 +396,7 @@ class ViewThreadFragment : } override fun onViewThread(position: Int) { - val status = adapter.currentList[position] + val status = adapter?.currentList?.getOrNull(position) ?: return if (thisThreadsStatusId == status.id) { // If already viewing this thread, don't reopen it. return @@ -417,11 +421,13 @@ class ViewThreadFragment : } override fun onExpandedChange(expanded: Boolean, position: Int) { - viewModel.changeExpanded(expanded, adapter.currentList[position]) + val status = adapter?.currentList?.getOrNull(position) ?: return + viewModel.changeExpanded(expanded, status) } override fun onContentHiddenChange(isShowing: Boolean, position: Int) { - viewModel.changeContentShowing(isShowing, adapter.currentList[position]) + val status = adapter?.currentList?.getOrNull(position) ?: return + viewModel.changeContentShowing(isShowing, status) } override fun onLoadMore(position: Int) { @@ -429,19 +435,20 @@ class ViewThreadFragment : } override fun onShowReblogs(position: Int) { - val statusId = adapter.currentList[position].id - val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId) + val status = adapter?.currentList?.getOrNull(position) ?: return + val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, status.id) requireActivity().startActivityWithSlideInAnimation(intent) } override fun onShowFavs(position: Int) { - val statusId = adapter.currentList[position].id - val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId) + val status = adapter?.currentList?.getOrNull(position) ?: return + val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, status.id) requireActivity().startActivityWithSlideInAnimation(intent) } override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - viewModel.changeContentCollapsed(isCollapsed, adapter.currentList[position]) + val status = adapter?.currentList?.getOrNull(position) ?: return + viewModel.changeContentCollapsed(isCollapsed, status) } override fun onViewTag(tag: String) { @@ -453,7 +460,7 @@ class ViewThreadFragment : } public override fun removeItem(position: Int) { - adapter.currentList.getOrNull(position)?.let { status -> + adapter?.currentList?.getOrNull(position)?.let { status -> if (status.isDetailed) { // the main status we are viewing is being removed, finish the activity activity?.finish() @@ -464,12 +471,12 @@ class ViewThreadFragment : } override fun onVoteInPoll(position: Int, choices: List) { - val status = adapter.currentList[position] + val status = adapter?.currentList?.getOrNull(position) ?: return viewModel.voteInPoll(choices, status) } override fun onShowEdits(position: Int) { - val status = adapter.currentList[position] + val status = adapter?.currentList?.getOrNull(position) ?: return val viewEditsFragment = ViewEditsFragment.newInstance(status.actionableId) parentFragmentManager.commit { @@ -485,7 +492,8 @@ class ViewThreadFragment : } override fun clearWarningAction(position: Int) { - viewModel.clearWarning(adapter.currentList[position]) + val status = adapter?.currentList?.getOrNull(position) ?: return + viewModel.clearWarning(status) } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt index 795707c8d..d51275e19 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt @@ -24,26 +24,26 @@ import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrThrow import at.connyduck.calladapter.networkresult.map import at.connyduck.calladapter.networkresult.onFailure +import at.connyduck.calladapter.networkresult.onSuccess import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.StatusChangedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent -import com.keylesspalace.tusky.components.timeline.toViewData +import com.keylesspalace.tusky.components.timeline.toStatus import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.entity.Filter -import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases -import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData import com.squareup.moshi.Moshi +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -57,6 +57,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +@HiltViewModel class ViewThreadViewModel @Inject constructor( private val api: MastodonApi, private val filterModel: FilterModel, @@ -67,6 +68,8 @@ class ViewThreadViewModel @Inject constructor( private val moshi: Moshi ) : ViewModel() { + private val activeAccount = accountManager.activeAccount!! + private val _uiState = MutableStateFlow(ThreadUiState.Loading as ThreadUiState) val uiState: Flow = _uiState.asStateFlow() @@ -79,14 +82,10 @@ class ViewThreadViewModel @Inject constructor( var isInitialLoad: Boolean = true - private val alwaysShowSensitiveMedia: Boolean - private val alwaysOpenSpoiler: Boolean + private val alwaysShowSensitiveMedia: Boolean = activeAccount.alwaysShowSensitiveMedia + private val alwaysOpenSpoiler: Boolean = activeAccount.alwaysOpenSpoiler init { - val activeAccount = accountManager.activeAccount - alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false - alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false - viewModelScope.launch { eventHub.events .collect { event -> @@ -98,8 +97,6 @@ class ViewThreadViewModel @Inject constructor( } } } - - loadFilters() } fun loadThread(id: String) { @@ -107,25 +104,22 @@ class ViewThreadViewModel @Inject constructor( viewModelScope.launch { Log.d(TAG, "Finding status with: $id") + val filterCall = async { filterModel.init(Filter.Kind.THREAD) } + val contextCall = async { api.statusContext(id) } - val timelineStatus = db.timelineDao().getStatus(accountManager.activeAccount!!.id, id) + val statusAndAccount = db.timelineStatusDao().getStatusWithAccount(activeAccount.id, id) - var detailedStatus = if (timelineStatus != null) { + var detailedStatus = if (statusAndAccount != null) { Log.d(TAG, "Loaded status from local timeline") - val viewData = timelineStatus.toViewData( - moshi, + StatusViewData.Concrete( + status = statusAndAccount.first.toStatus(statusAndAccount.second), + isExpanded = statusAndAccount.first.expanded, + isShowingContent = statusAndAccount.first.contentShowing, + isCollapsed = statusAndAccount.first.contentCollapsed, isDetailed = true, - ) as StatusViewData.Concrete - - // Return the correct status, depending on which one matched. If you do not do - // this the status IDs will be different between the status that's displayed with - // ThreadUiState.LoadingThread and ThreadUiState.Success, even though the apparent - // status content is the same. Then the status flickers as it is drawn twice. - if (viewData.actionableId == id) { - viewData.actionable.toViewData(isDetailed = true) - } else { - viewData - } + // NOTE repliedToAccount is null here: this avoids showing "in reply to" over every post + translation = null + ) } else { Log.d(TAG, "Loaded status from network") val result = api.status(id).getOrElse { exception -> @@ -143,17 +137,19 @@ class ViewThreadViewModel @Inject constructor( // If the detailedStatus was loaded from the database it might be out-of-date // compared to the remote one. Now the user has a working UI do a background fetch // for the status. Ignore errors, the user still has a functioning UI if the fetch - // failed. - if (timelineStatus != null) { - api.status(id).getOrNull()?.let { result -> - db.timelineDao().update( - accountId = accountManager.activeAccount!!.id, - status = result + // failed. Update the database when the fetch was successful. + if (statusAndAccount != null) { + api.status(id).onSuccess { result -> + db.timelineStatusDao().update( + tuskyAccountId = activeAccount.id, + status = result, + moshi = moshi ) detailedStatus = result.toViewData(isDetailed = true) } } + filterCall.await() // make sure FilterModel is initialized before using it val contextResult = contextCall.await() contextResult.fold({ statusContext -> @@ -200,9 +196,9 @@ class ViewThreadViewModel @Inject constructor( } } - fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { + fun reblog(reblog: Boolean, status: StatusViewData.Concrete, visibility: Status.Visibility = Status.Visibility.PUBLIC): Job = viewModelScope.launch { try { - timelineCases.reblog(status.actionableId, reblog).getOrThrow() + timelineCases.reblog(status.actionableId, reblog, visibility).getOrThrow() } catch (t: Exception) { ifExpected(t) { Log.d(TAG, "Failed to reblog status " + status.actionableId, t) @@ -422,45 +418,9 @@ class ViewThreadViewModel @Inject constructor( return RevealButtonState.NO_BUTTON } - private fun loadFilters() { - viewModelScope.launch { - api.getFilters().fold( - { - filterModel.kind = Filter.Kind.THREAD - updateStatuses() - }, - { throwable -> - if (throwable.isHttpNotFound()) { - val filters = api.getFiltersV1().getOrElse { - Log.w(TAG, "Failed to fetch filters", it) - return@launch - } - - filterModel.initWithFilters( - filters.filter { filter -> filter.context.contains(FilterV1.THREAD) } - ) - updateStatuses() - } else { - Log.e(TAG, "Error getting filters", throwable) - } - } - ) - } - } - - private fun updateStatuses() { - updateSuccess { uiState -> - val statuses = uiState.statusViewData.filter() - uiState.copy( - statusViewData = statuses, - revealButton = statuses.getRevealButtonState() - ) - } - } - private fun List.filter(): List { return filter { status -> - if (status.isDetailed) { + if (status.isDetailed || status.status.account.id == activeAccount.accountId) { true } else { status.filterAction = filterModel.shouldFilterStatus(status.status) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt index 006d90b02..aef461e15 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt @@ -26,9 +26,9 @@ import com.keylesspalace.tusky.entity.StatusEdit import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.AbsoluteTimeFormatter import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.BlurhashDrawable import com.keylesspalace.tusky.util.TuskyTagHandler import com.keylesspalace.tusky.util.aspectRatios -import com.keylesspalace.tusky.util.decodeBlurHash import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.parseAsMastodonHtml @@ -130,7 +130,7 @@ class ViewEditsAdapter( emojifiedText, emptyList(), emptyList(), - listener + listener, ) if (edit.poll == null) { @@ -186,7 +186,7 @@ class ViewEditsAdapter( val blurhash = attachment.blurhash val placeholder: Drawable = if (blurhash != null && useBlurhash) { - decodeBlurHash(context, blurhash) + BlurhashDrawable(context, blurhash) } else { ColorDrawable(MaterialColors.getColor(imageView, R.attr.colorBackgroundAccent)) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt index 2a25ee872..10301ca15 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.components.viewthread.edits +import android.content.SharedPreferences import android.os.Bundle import android.util.Log import android.view.Menu @@ -27,7 +28,6 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator @@ -38,8 +38,6 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.databinding.FragmentViewEditsBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.emojify @@ -53,20 +51,21 @@ 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.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.launch +@AndroidEntryPoint class ViewEditsFragment : Fragment(R.layout.fragment_view_edits), LinkListener, OnRefreshListener, - MenuProvider, - Injectable { + MenuProvider { @Inject - lateinit var viewModelFactory: ViewModelFactory + lateinit var preferences: SharedPreferences - private val viewModel: ViewEditsViewModel by viewModels { viewModelFactory } + private val viewModel: ViewEditsViewModel by viewModels() private val binding by viewBinding(FragmentViewEditsBinding::bind) @@ -76,7 +75,6 @@ class ViewEditsFragment : requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) binding.swipeRefreshLayout.setOnRefreshListener(this) - binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) binding.recyclerView.setHasFixedSize(true) binding.recyclerView.layoutManager = LinearLayoutManager(context) @@ -86,7 +84,6 @@ class ViewEditsFragment : (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false statusId = requireArguments().getString(STATUS_ID_EXTRA)!! - val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt index a110c57a9..96b7ecd1b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt @@ -22,6 +22,7 @@ import com.keylesspalace.tusky.components.viewthread.edits.EditsTagHandler.Compa import com.keylesspalace.tusky.components.viewthread.edits.EditsTagHandler.Companion.INSERTED_TEXT_EL import com.keylesspalace.tusky.entity.StatusEdit import com.keylesspalace.tusky.network.MastodonApi +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -43,13 +44,14 @@ import org.pageseeder.diffx.xml.NamespaceSet import org.pageseeder.xmlwriter.XML.NamespaceAware import org.pageseeder.xmlwriter.XMLStringWriter +@HiltViewModel class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() { private val _uiState = MutableStateFlow(EditsUiState.Initial as EditsUiState) val uiState: StateFlow = _uiState.asStateFlow() /** The API call to fetch edit history returned less than two items */ - object MissingEditsException : Exception() + class MissingEditsException : Exception() fun loadEdits(statusId: String, force: Boolean = false, refreshing: Boolean = false) { if (!force && _uiState.value !is EditsUiState.Initial) return @@ -69,7 +71,7 @@ class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : Vie // `edits` might have fewer than the minimum number of entries because of // https://github.com/mastodon/mastodon/issues/25398. if (edits.size < 2) { - _uiState.value = EditsUiState.Error(MissingEditsException) + _uiState.value = EditsUiState.Error(MissingEditsException()) return@launch } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt deleted file mode 100644 index 40098593c..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ /dev/null @@ -1,150 +0,0 @@ -/* Copyright 2018 Conny Duck - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.db - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.Index -import androidx.room.PrimaryKey -import androidx.room.TypeConverters -import com.keylesspalace.tusky.TabData -import com.keylesspalace.tusky.defaultTabs -import com.keylesspalace.tusky.entity.Emoji -import com.keylesspalace.tusky.entity.Status - -@Entity( - indices = [ - Index( - value = ["domain", "accountId"], - unique = true - ) - ] -) -@TypeConverters(Converters::class) -data class AccountEntity( - @field:PrimaryKey(autoGenerate = true) var id: Long, - val domain: String, - var accessToken: String, - // nullable for backward compatibility - var clientId: String?, - // nullable for backward compatibility - var clientSecret: String?, - var isActive: Boolean, - var accountId: String = "", - var username: String = "", - var displayName: String = "", - var profilePictureUrl: String = "", - var notificationsEnabled: Boolean = true, - var notificationsMentioned: Boolean = true, - var notificationsFollowed: Boolean = true, - var notificationsFollowRequested: Boolean = false, - var notificationsReblogged: Boolean = true, - var notificationsFavorited: Boolean = true, - var notificationsPolls: Boolean = true, - var notificationsSubscriptions: Boolean = true, - var notificationsSignUps: Boolean = true, - var notificationsUpdates: Boolean = true, - var notificationsReports: Boolean = true, - var notificationSound: Boolean = true, - var notificationVibration: Boolean = true, - var notificationLight: Boolean = true, - var defaultPostPrivacy: Status.Visibility = Status.Visibility.PUBLIC, - var defaultMediaSensitivity: Boolean = false, - var defaultPostLanguage: String = "", - var alwaysShowSensitiveMedia: Boolean = false, - /** True if content behind a content warning is shown by default */ - var alwaysOpenSpoiler: Boolean = false, - - /** - * True if the "Download media previews" preference is true. This implies - * that media previews are shown as well as downloaded. - */ - var mediaPreviewEnabled: Boolean = true, - /** - * ID of the last notification the user read on the Notification, list, and should be restored - * to view when the user returns to the list. - * - * May not be the ID of the most recent notification if the user has scrolled down the list. - */ - var lastNotificationId: String = "0", - /** - * ID of the most recent Mastodon notification that Tusky has fetched to show as an - * Android notification. - */ - @ColumnInfo(defaultValue = "0") - var notificationMarkerId: String = "0", - var emojis: List = emptyList(), - var tabPreferences: List = defaultTabs(), - var notificationsFilter: String = "[\"follow_request\"]", - // Scope cannot be changed without re-login, so store it in case - // the scope needs to be changed in the future - var oauthScopes: String = "", - var unifiedPushUrl: String = "", - var pushPubKey: String = "", - var pushPrivKey: String = "", - var pushAuth: String = "", - var pushServerKey: String = "", - - /** - * ID of the status at the top of the visible list in the home timeline when the - * user navigated away. - */ - var lastVisibleHomeTimelineStatusId: String? = null, - - /** true if the connected Mastodon account is locked (has to manually approve all follow requests **/ - @ColumnInfo(defaultValue = "0") - var locked: Boolean = false, - - @ColumnInfo(defaultValue = "0") - var hasDirectMessageBadge: Boolean = false, - - var isShowHomeBoosts: Boolean = true, - var isShowHomeReplies: Boolean = true, - var isShowHomeSelfBoosts: Boolean = true -) { - - val identifier: String - get() = "$domain:$accountId" - - val fullName: String - get() = "@$username@$domain" - - fun logout() { - // deleting credentials so they cannot be used again - accessToken = "" - clientId = null - clientSecret = null - } - - fun isLoggedIn() = accessToken.isNotEmpty() - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as AccountEntity - - if (id == other.id) return true - return domain == other.domain && accountId == other.accountId - } - - override fun hashCode(): Int { - var result = id.hashCode() - result = 31 * result + domain.hashCode() - result = 31 * result + accountId.hashCode() - return result - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index d7397ea4e..00459255d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -15,40 +15,67 @@ package com.keylesspalace.tusky.db -import android.content.Context +import android.content.SharedPreferences import android.util.Log -import androidx.preference.PreferenceManager +import androidx.room.withTransaction +import com.keylesspalace.tusky.db.dao.AccountDao +import com.keylesspalace.tusky.db.entity.AccountEntity +import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.settings.PrefKeys import java.util.Locale import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.runBlocking /** - * This class caches the account database and handles all account related operations - * @author ConnyDuck + * This class is the main interface to all account related operations. */ private const val TAG = "AccountManager" @Singleton -class AccountManager @Inject constructor(db: AppDatabase) { - - @Volatile - var activeAccount: AccountEntity? = null - private set - - var accounts: MutableList = mutableListOf() - private set +class AccountManager @Inject constructor( + private val db: AppDatabase, + private val preferences: SharedPreferences, + @ApplicationScope private val applicationScope: CoroutineScope +) { private val accountDao: AccountDao = db.accountDao() - init { - accounts = accountDao.loadAll().toMutableList() + /** A StateFlow that will update everytime an account in the database changes, is added or removed. + * The first account is the currently active one. + */ + val accountsFlow: StateFlow> = runBlocking { + accountDao.allAccounts() + .stateIn(CoroutineScope(applicationScope.coroutineContext + Dispatchers.IO)) + } - activeAccount = accounts.find { acc -> acc.isActive } - ?: accounts.firstOrNull()?.also { acc -> acc.isActive = true } + /** A snapshot of all accounts in the database with the active account first */ + val accounts: List + get() = accountsFlow.value + + /** A snapshot currently active account, if there is one */ + val activeAccount: AccountEntity? + get() = accounts.firstOrNull() + + /** Returns a StateFlow for updates to the currently active account. + * Note that always the same account will be emitted, + * even if it is no longer active and that it will emit null when the account got removed. + * @param scope the [CoroutineScope] this flow will be active in. + */ + fun activeAccount(scope: CoroutineScope): StateFlow { + val activeAccount = activeAccount + return accountsFlow.map { accounts -> + accounts.find { account -> activeAccount?.id == account.id } + }.stateIn(scope, SharingStarted.Lazily, activeAccount) } /** @@ -60,34 +87,32 @@ class AccountManager @Inject constructor(db: AppDatabase) { * @param oauthScopes the oauth scopes granted to the account * @param newAccount the [Account] as returned by the Mastodon Api */ - fun addAccount( + suspend fun addAccount( accessToken: String, domain: String, clientId: String, clientSecret: String, oauthScopes: String, newAccount: Account - ) { + ) = db.withTransaction { activeAccount?.let { - it.isActive = false Log.d(TAG, "addAccount: saving account with id " + it.id) - - accountDao.insertOrReplace(it) + accountDao.insertOrReplace(it.copy(isActive = false)) } // check if this is a relogin with an existing account, if yes update it, otherwise create a new one - val existingAccountIndex = accounts.indexOfFirst { account -> + val existingAccount = accounts.find { account -> domain == account.domain && newAccount.id == account.accountId } - val newAccountEntity = if (existingAccountIndex != -1) { - accounts[existingAccountIndex].copy( + val newAccountEntity = if (existingAccount != null) { + existingAccount.copy( accessToken = accessToken, clientId = clientId, clientSecret = clientSecret, oauthScopes = oauthScopes, isActive = true - ).also { accounts[existingAccountIndex] = it } + ) } else { - val maxAccountId = accounts.maxByOrNull { it.id }?.id ?: 0 + val maxAccountId = accounts.maxOfOrNull { it.id } ?: 0 val newAccountId = maxAccountId + 1 AccountEntity( id = newAccountId, @@ -98,108 +123,85 @@ class AccountManager @Inject constructor(db: AppDatabase) { oauthScopes = oauthScopes, isActive = true, accountId = newAccount.id - ).also { accounts.add(it) } + ) } - - activeAccount = newAccountEntity - updateActiveAccount(newAccount) + updateAccount(newAccountEntity, newAccount) } /** * Saves an already known account to the database. * New accounts must be created with [addAccount] - * @param account the account to save + * @param account The account to save + * @param changer make the changes to save here - this is to make sure no stale data gets re-saved to the database */ - fun saveAccount(account: AccountEntity) { - if (account.id != 0L) { - Log.d(TAG, "saveAccount: saving account with id " + account.id) - accountDao.insertOrReplace(account) + suspend fun updateAccount(account: AccountEntity, changer: AccountEntity.() -> AccountEntity) { + accounts.find { it.id == account.id }?.let { acc -> + Log.d(TAG, "updateAccount: saving account with id " + acc.id) + accountDao.insertOrReplace(changer(acc)) } } /** - * Logs the current account out by deleting all data of the account. + * Updates an account with new information from the Mastodon api + * and saves it in the database. + * @param accountEntity the [AccountEntity] to update + * @param account the [Account] object which the newest data from the api + */ + suspend fun updateAccount(accountEntity: AccountEntity, account: Account) { + // make sure no stale data gets re-saved to the database + val accountToUpdate = accounts.find { it.id == accountEntity.id } ?: accountEntity + + val newAccount = accountToUpdate.copy( + accountId = account.id, + username = account.username, + displayName = account.name, + profilePictureUrl = account.avatar, + profileHeaderUrl = account.header, + defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC, + defaultPostLanguage = account.source?.language.orEmpty(), + defaultMediaSensitivity = account.source?.sensitive == true, + emojis = account.emojis, + locked = account.locked + ) + + Log.d(TAG, "updateAccount: saving account with id " + accountToUpdate.id) + accountDao.insertOrReplace(newAccount) + } + + /** + * Removes an account from the database. * @return the new active account, or null if no other account was found */ - fun logActiveAccountOut(): AccountEntity? { - return activeAccount?.let { account -> + suspend fun remove(account: AccountEntity): AccountEntity? = db.withTransaction { + Log.d(TAG, "remove: deleting account with id " + account.id) + accountDao.delete(account) - account.logout() - - accounts.remove(account) - accountDao.delete(account) - - if (accounts.size > 0) { - accounts[0].isActive = true - activeAccount = accounts[0] - Log.d(TAG, "logActiveAccountOut: saving account with id " + accounts[0].id) - accountDao.insertOrReplace(accounts[0]) - } else { - activeAccount = null - } - activeAccount + accounts.find { it.id != account.id }?.let { otherAccount -> + val otherAccountActive = otherAccount.copy( + isActive = true + ) + Log.d(TAG, "remove: saving account with id " + otherAccountActive.id) + accountDao.insertOrReplace(otherAccountActive) + otherAccountActive } } /** - * updates the current account with new information from the mastodon api - * and saves it in the database - * @param account the [Account] object returned from the api - */ - fun updateActiveAccount(account: Account) { - activeAccount?.let { - it.accountId = account.id - it.username = account.username - it.displayName = account.name - it.profilePictureUrl = account.avatar - it.defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC - it.defaultPostLanguage = account.source?.language.orEmpty() - it.defaultMediaSensitivity = account.source?.sensitive ?: false - it.emojis = account.emojis.orEmpty() - it.locked = account.locked - - Log.d(TAG, "updateActiveAccount: saving account with id " + it.id) - accountDao.insertOrReplace(it) - } - } - - /** - * changes the active account + * Changes the active account * @param accountId the database id of the new active account */ - fun setActiveAccount(accountId: Long) { + suspend fun setActiveAccount(accountId: Long) = db.withTransaction { + Log.d(TAG, "setActiveAccount $accountId") + val newActiveAccount = accounts.find { (id) -> id == accountId - } ?: return // invalid accountId passed, do nothing + } ?: return@withTransaction // invalid accountId passed, do nothing activeAccount?.let { - Log.d(TAG, "setActiveAccount: saving account with id " + it.id) - it.isActive = false - saveAccount(it) + accountDao.insertOrReplace(it.copy(isActive = false)) } - activeAccount = newActiveAccount - - activeAccount?.let { - it.isActive = true - accountDao.insertOrReplace(it) - } - } - - /** - * @return an immutable list of all accounts in the database with the active account first - */ - fun getAllAccountsOrderedByActive(): List { - val accountsCopy = accounts.toMutableList() - accountsCopy.sortWith { l, r -> - when { - l.isActive && !r.isActive -> -1 - r.isActive && !l.isActive -> 1 - else -> 0 - } - } - - return accountsCopy + accountDao.insertOrReplace(newActiveAccount.copy(isActive = true)) } /** @@ -234,9 +236,8 @@ class AccountManager @Inject constructor(db: AppDatabase) { /** * @return true if the name of the currently-selected account should be displayed in UIs */ - fun shouldDisplaySelfUsername(context: Context): Boolean { - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - val showUsernamePreference = sharedPreferences.getString( + fun shouldDisplaySelfUsername(): Boolean { + val showUsernamePreference = preferences.getString( PrefKeys.SHOW_SELF_USERNAME, "disambiguate" ) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index 39261cb55..776446025 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -27,6 +27,23 @@ import androidx.sqlite.db.SupportSQLiteDatabase; import com.keylesspalace.tusky.TabDataKt; import com.keylesspalace.tusky.components.conversation.ConversationEntity; +import com.keylesspalace.tusky.db.dao.AccountDao; +import com.keylesspalace.tusky.db.dao.DraftDao; +import com.keylesspalace.tusky.db.dao.InstanceDao; +import com.keylesspalace.tusky.db.dao.NotificationPolicyDao; +import com.keylesspalace.tusky.db.dao.NotificationsDao; +import com.keylesspalace.tusky.db.dao.TimelineAccountDao; +import com.keylesspalace.tusky.db.dao.TimelineDao; +import com.keylesspalace.tusky.db.dao.TimelineStatusDao; +import com.keylesspalace.tusky.db.entity.AccountEntity; +import com.keylesspalace.tusky.db.entity.DraftEntity; +import com.keylesspalace.tusky.db.entity.HomeTimelineEntity; +import com.keylesspalace.tusky.db.entity.InstanceEntity; +import com.keylesspalace.tusky.db.entity.NotificationEntity; +import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity; +import com.keylesspalace.tusky.db.entity.NotificationReportEntity; +import com.keylesspalace.tusky.db.entity.TimelineAccountEntity; +import com.keylesspalace.tusky.db.entity.TimelineStatusEntity; import java.io.File; @@ -40,18 +57,25 @@ import java.io.File; InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, - ConversationEntity.class + ConversationEntity.class, + NotificationEntity.class, + NotificationReportEntity.class, + HomeTimelineEntity.class, + NotificationPolicyEntity.class }, // Note: Starting with version 54, database versions in Tusky are always even. // This is to reserve odd version numbers for use by forks. - version = 58, + version = 68, autoMigrations = { @AutoMigration(from = 48, to = 49), @AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class), @AutoMigration(from = 50, to = 51), @AutoMigration(from = 51, to = 52), @AutoMigration(from = 53, to = 54), // hasDirectMessageBadge in AccountEntity - @AutoMigration(from = 56, to = 58) // translationEnabled in InstanceEntity/InstanceInfoEntity + @AutoMigration(from = 56, to = 58), // translationEnabled in InstanceEntity/InstanceInfoEntity + @AutoMigration(from = 62, to = 64), // filterV2Available in InstanceEntity + @AutoMigration(from = 64, to = 66), // added profileHeaderUrl to AccountEntity + @AutoMigration(from = 66, to = 68, spec = AppDatabase.MIGRATION_66_68.class), // added event and moderationAction to NotificationEntity, new NotificationPolicyEntity } ) public abstract class AppDatabase extends RoomDatabase { @@ -61,6 +85,10 @@ public abstract class AppDatabase extends RoomDatabase { @NonNull public abstract ConversationsDao conversationDao(); @NonNull public abstract TimelineDao timelineDao(); @NonNull public abstract DraftDao draftDao(); + @NonNull public abstract NotificationsDao notificationsDao(); + @NonNull public abstract TimelineStatusDao timelineStatusDao(); + @NonNull public abstract TimelineAccountDao timelineAccountDao(); + @NonNull public abstract NotificationPolicyDao notificationPolicyDao(); public static final Migration MIGRATION_2_3 = new Migration(2, 3) { @Override @@ -698,4 +726,137 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `isShowHomeSelfBoosts` INTEGER NOT NULL DEFAULT 1"); } }; + + public static final Migration MIGRATION_58_60 = new Migration(58, 60) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + // drop the old tables - they are only caches anyway + database.execSQL("DROP TABLE `TimelineStatusEntity`"); + database.execSQL("DROP TABLE `TimelineAccountEntity`"); + + // create the new tables + database.execSQL(""" + CREATE TABLE IF NOT EXISTS `TimelineAccountEntity` ( + `serverId` TEXT NOT NULL, + `tuskyAccountId` 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`, `tuskyAccountId`) + )""" + ); + database.execSQL(""" + CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` ( + `serverId` TEXT NOT NULL, + `url` TEXT, + `tuskyAccountId` INTEGER NOT NULL, + `authorServerId` TEXT NOT NULL, + `inReplyToId` TEXT, + `inReplyToAccountId` TEXT, + `content` TEXT NOT NULL, + `createdAt` INTEGER NOT NULL, + `editedAt` INTEGER, + `emojis` TEXT NOT NULL, + `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 NOT NULL, + `mentions` TEXT NOT NULL, + `tags` TEXT NOT NULL, + `application` TEXT, + `poll` TEXT, + `muted` INTEGER NOT NULL, + `expanded` INTEGER NOT NULL, + `contentCollapsed` INTEGER NOT NULL, + `contentShowing` INTEGER NOT NULL, + `pinned` INTEGER NOT NULL, + `card` TEXT, `language` TEXT, + `filtered` TEXT NOT NULL, + PRIMARY KEY(`serverId`, `tuskyAccountId`), + FOREIGN KEY(`authorServerId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION + )""" + ); + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_tuskyAccountId` ON `TimelineStatusEntity` (`authorServerId`, `tuskyAccountId`)" + ); + database.execSQL(""" + CREATE TABLE IF NOT EXISTS `HomeTimelineEntity` ( + `tuskyAccountId` INTEGER NOT NULL, + `id` TEXT NOT NULL, + `statusId` TEXT, + `reblogAccountId` TEXT, + `loading` INTEGER NOT NULL, + PRIMARY KEY(`id`, `tuskyAccountId`), + FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION, + FOREIGN KEY(`reblogAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION + )""" + ); + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_statusId_tuskyAccountId` ON `HomeTimelineEntity` (`statusId`, `tuskyAccountId`)" + ); + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_reblogAccountId_tuskyAccountId` ON `HomeTimelineEntity` (`reblogAccountId`, `tuskyAccountId`)" + ); + database.execSQL(""" + CREATE TABLE IF NOT EXISTS `NotificationReportEntity`( + `tuskyAccountId` INTEGER NOT NULL, + `serverId` TEXT NOT NULL, + `category` TEXT NOT NULL, + `statusIds` TEXT, + `createdAt` INTEGER NOT NULL, + `targetAccountId` TEXT, + PRIMARY KEY(`serverId`, `tuskyAccountId`), + FOREIGN KEY(`targetAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION + )""" + ); + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_NotificationReportEntity_targetAccountId_tuskyAccountId` ON `NotificationReportEntity` (`targetAccountId`, `tuskyAccountId`)" + ); + database.execSQL(""" + CREATE TABLE IF NOT EXISTS `NotificationEntity` ( + `tuskyAccountId` INTEGER NOT NULL, + `type` TEXT, + `id` TEXT NOT NULL, + `accountId` TEXT, + `statusId` TEXT, + `reportId` TEXT, + `loading` INTEGER NOT NULL, + PRIMARY KEY(`id`, `tuskyAccountId`), + FOREIGN KEY(`accountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION, + FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION, + FOREIGN KEY(`reportId`, `tuskyAccountId`) REFERENCES `NotificationReportEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION + )""" + ); + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_accountId_tuskyAccountId` ON `NotificationEntity` (`accountId`, `tuskyAccountId`)" + ); + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_statusId_tuskyAccountId` ON `NotificationEntity` (`statusId`, `tuskyAccountId`)" + ); + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_reportId_tuskyAccountId` ON `NotificationEntity` (`reportId`, `tuskyAccountId`)" + ); + } + }; + + public static final Migration MIGRATION_60_62 = new Migration(60, 62) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultReplyPrivacy` INTEGER NOT NULL DEFAULT 0"); + } + }; + + @DeleteColumn(tableName = "AccountEntity", columnName = "notificationsSignUps") + @DeleteColumn(tableName = "AccountEntity", columnName = "notificationsReports") + static class MIGRATION_66_68 implements AutoMigrationSpec { } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt index 026978802..3696b0c88 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -19,15 +19,22 @@ import androidx.room.ProvidedTypeConverter import androidx.room.TypeConverter import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity +import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData import com.keylesspalace.tusky.createTabDataFromId +import com.keylesspalace.tusky.db.entity.DraftAttachment +import com.keylesspalace.tusky.entity.AccountWarning import com.keylesspalace.tusky.entity.Attachment -import com.keylesspalace.tusky.entity.Card import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.FilterResult import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.NewPoll +import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.PreviewCard +import com.keylesspalace.tusky.entity.RelationshipSeveranceEvent import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.notificationTypeFromString +import com.keylesspalace.tusky.settings.DefaultReplyVisibility import com.squareup.moshi.Moshi import com.squareup.moshi.adapter import java.net.URLDecoder @@ -35,6 +42,8 @@ import java.net.URLEncoder import java.util.Date import javax.inject.Inject import javax.inject.Singleton +import kotlin.collections.forEach +import org.json.JSONArray @OptIn(ExperimentalStdlibApi::class) @ProvidedTypeConverter @@ -55,12 +64,22 @@ class Converters @Inject constructor( @TypeConverter fun visibilityToInt(visibility: Status.Visibility?): Int { - return visibility?.num ?: Status.Visibility.UNKNOWN.num + return visibility?.int ?: Status.Visibility.UNKNOWN.int } @TypeConverter fun intToVisibility(visibility: Int): Status.Visibility { - return Status.Visibility.byNum(visibility) + return Status.Visibility.fromInt(visibility) + } + + @TypeConverter + fun defaultReplyVisibilityToInt(visibility: DefaultReplyVisibility?): Int { + return visibility?.int ?: DefaultReplyVisibility.MATCH_DEFAULT_POST_VISIBILITY.int + } + + @TypeConverter + fun intToDefaultReplyVisibility(visibility: Int): DefaultReplyVisibility { + return DefaultReplyVisibility.fromInt(visibility) } @TypeConverter @@ -184,7 +203,89 @@ class Converters @Inject constructor( } @TypeConverter - fun cardToJson(card: Card?): String { - return moshi.adapter().toJson(card) + fun cardToJson(card: PreviewCard?): String { + return moshi.adapter().toJson(card) + } + + @TypeConverter + fun jsonToCard(cardJson: String?): PreviewCard? { + return cardJson?.let { moshi.adapter().fromJson(cardJson) } + } + + @TypeConverter + fun stringListToJson(list: List?): String? { + return moshi.adapter?>().toJson(list) + } + + @TypeConverter + fun jsonToStringList(listJson: String?): List? { + return listJson?.let { moshi.adapter?>().fromJson(it) } + } + + @TypeConverter + fun applicationToJson(application: Status.Application?): String { + return moshi.adapter().toJson(application) + } + + @TypeConverter + fun jsonToApplication(applicationJson: String?): Status.Application? { + return applicationJson?.let { moshi.adapter().fromJson(it) } + } + + @TypeConverter + fun notificationChannelDataListToJson(data: Set?): String { + val array = JSONArray() + data?.forEach { + array.put(it.name) + } + return array.toString() + } + + @TypeConverter + fun jsonToNotificationChannelDataList(data: String?): Set { + val ret = HashSet() + data?.let { + val array = JSONArray(data) + for (i in 0 until array.length()) { + val item = array.getString(i) + try { + val type = NotificationChannelData.valueOf(item) + ret.add(type) + } catch (_: IllegalArgumentException) { + // ignore, this can happen because we stored individual notification types and not channels before + } + } + } + return ret + } + + @TypeConverter + fun relationshipSeveranceEventToJson(event: RelationshipSeveranceEvent?): String { + return moshi.adapter().toJson(event) + } + + @TypeConverter + fun jsonToRelationshipSeveranceEvent(eventJson: String?): RelationshipSeveranceEvent? { + return eventJson?.let { moshi.adapter().fromJson(it) } + } + + @TypeConverter + fun accountWarningToJson(accountWarning: AccountWarning?): String { + return moshi.adapter().toJson(accountWarning) + } + + @TypeConverter + fun jsonToAccountWarning(accountWarningJson: String?): AccountWarning? { + return accountWarningJson?.let { moshi.adapter().fromJson(it) } + } + + @TypeConverter + fun accountWarningToJson(notificationType: Notification.Type): String { + return notificationType.name + } + + @TypeConverter + fun jsonToNotificationType(notificationTypeJson: String): Notification.Type { + return notificationTypeFromString(notificationTypeJson) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DatabaseCleaner.kt b/app/src/main/java/com/keylesspalace/tusky/db/DatabaseCleaner.kt new file mode 100644 index 000000000..0d2ee9c02 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/DatabaseCleaner.kt @@ -0,0 +1,66 @@ +/* Copyright 2024 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.db + +import androidx.room.withTransaction +import com.keylesspalace.tusky.db.entity.HomeTimelineEntity +import com.keylesspalace.tusky.db.entity.NotificationEntity +import com.keylesspalace.tusky.db.entity.NotificationReportEntity +import com.keylesspalace.tusky.db.entity.TimelineAccountEntity +import com.keylesspalace.tusky.db.entity.TimelineStatusEntity +import javax.inject.Inject + +class DatabaseCleaner @Inject constructor( + private val db: AppDatabase +) { + /** + * Cleans the [HomeTimelineEntity], [TimelineStatusEntity], [TimelineAccountEntity], [NotificationEntity] and [NotificationReportEntity] tables from old entries. + * Should be regularly run to prevent the database from growing too big. + * @param tuskyAccountId id of the account for which to clean tables + * @param timelineLimit how many timeline items to keep + * @param notificationLimit how many notifications to keep + */ + suspend fun cleanupOldData( + tuskyAccountId: Long, + timelineLimit: Int, + notificationLimit: Int + ) { + db.withTransaction { + // the order here is important - foreign key constraints must not be violated + db.notificationsDao().cleanupNotifications(tuskyAccountId, notificationLimit) + db.notificationsDao().cleanupReports(tuskyAccountId) + db.timelineDao().cleanupHomeTimeline(tuskyAccountId, timelineLimit) + db.timelineStatusDao().cleanupStatuses(tuskyAccountId) + db.timelineAccountDao().cleanupAccounts(tuskyAccountId) + } + } + + /** + * Deletes everything from the [HomeTimelineEntity], [TimelineStatusEntity], [TimelineAccountEntity], [NotificationEntity] and [NotificationReportEntity] tables for one user. + * Intended to be used when a user logs out. + * @param tuskyAccountId id of the account for which to clean tables + */ + suspend fun cleanupEverything(tuskyAccountId: Long) { + db.withTransaction { + // the order here is important - foreign key constraints must not be violated + db.notificationsDao().removeAllNotifications(tuskyAccountId) + db.notificationsDao().removeAllReports(tuskyAccountId) + db.timelineDao().removeAllHomeTimelineItems(tuskyAccountId) + db.timelineStatusDao().removeAllStatuses(tuskyAccountId) + db.timelineAccountDao().removeAllAccounts(tuskyAccountId) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt index 5b2993f68..291d7d10b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt @@ -19,13 +19,16 @@ import android.content.Context import android.content.DialogInterface import android.util.Log import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.drafts.DraftsActivity +import com.keylesspalace.tusky.db.dao.DraftDao import javax.inject.Inject -import javax.inject.Singleton import kotlinx.coroutines.launch /** @@ -36,51 +39,55 @@ import kotlinx.coroutines.launch private const val TAG = "DraftsAlert" -@Singleton -class DraftsAlert @Inject constructor(db: AppDatabase) { +class DraftsAlert @Inject constructor( + db: AppDatabase, + private val accountManager: AccountManager +) { // For tracking when a media upload fails in the service private val draftDao: DraftDao = db.draftDao() - @Inject - lateinit var accountManager: AccountManager + private var dialog: AlertDialog? = null fun observeInContext(context: T, showAlert: Boolean) where T : Context, T : LifecycleOwner { accountManager.activeAccount?.let { activeAccount -> val coroutineScope = context.lifecycleScope - // Assume a single MainActivity, AccountActivity or DraftsActivity never sees more then one user id in its lifetime. + // One activity never sees more then one user id in its lifetime. val activeAccountId = activeAccount.id - val draftsNeedUserAlert = draftDao.draftsNeedUserAlert(activeAccountId) - // observe ensures that this gets called at the most appropriate moment wrt the context lifecycle— // at init, at next onResume, or immediately if the context is resumed already. coroutineScope.launch { - if (showAlert) { - draftsNeedUserAlert.collect { count -> - Log.d(TAG, "User id $activeAccountId changed: Notification-worthy draft count $count") - if (count > 0) { - AlertDialog.Builder(context) - .setTitle(R.string.action_post_failed) - .setMessage( - context.resources.getQuantityString(R.plurals.action_post_failed_detail, count) - ) - .setPositiveButton(R.string.action_post_failed_show_drafts) { _: DialogInterface?, _: Int -> - clearDraftsAlert(coroutineScope, activeAccountId) // User looked at drafts + context.repeatOnLifecycle(Lifecycle.State.RESUMED) { + val draftsNeedUserAlert = draftDao.draftsNeedUserAlert(activeAccountId) - val intent = DraftsActivity.newIntent(context) - context.startActivity(intent) - } - .setNegativeButton(R.string.action_post_failed_do_nothing) { _: DialogInterface?, _: Int -> - clearDraftsAlert(coroutineScope, activeAccountId) // User doesn't care - } - .show() + if (showAlert) { + draftDao.draftsNeedUserAlert(activeAccountId).collect { count -> + Log.d(TAG, "User id $activeAccountId changed: Notification-worthy draft count $count") + if (count > 0) { + dialog?.cancel() + dialog = MaterialAlertDialogBuilder(context) + .setTitle(R.string.action_post_failed) + .setMessage( + context.resources.getQuantityString(R.plurals.action_post_failed_detail, count) + ) + .setPositiveButton(R.string.action_post_failed_show_drafts) { _: DialogInterface?, _: Int -> + clearDraftsAlert(coroutineScope, activeAccountId) // User looked at drafts + + val intent = DraftsActivity.newIntent(context) + context.startActivity(intent) + } + .setNegativeButton(R.string.action_post_failed_do_nothing) { _: DialogInterface?, _: Int -> + clearDraftsAlert(coroutineScope, activeAccountId) // User doesn't care + } + .show() + } + } + } else { + draftsNeedUserAlert.collect { + Log.d(TAG, "User id $activeAccountId: Clean out notification-worthy drafts") + clearDraftsAlert(coroutineScope, activeAccountId) } - } - } else { - draftsNeedUserAlert.collect { - Log.d(TAG, "User id $activeAccountId: Clean out notification-worthy drafts") - clearDraftsAlert(coroutineScope, activeAccountId) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt deleted file mode 100644 index 3cd49baac..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ /dev/null @@ -1,346 +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.db - -import androidx.paging.PagingSource -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy.Companion.REPLACE -import androidx.room.Query -import androidx.room.TypeConverters -import com.keylesspalace.tusky.entity.Attachment -import com.keylesspalace.tusky.entity.Card -import com.keylesspalace.tusky.entity.Emoji -import com.keylesspalace.tusky.entity.HashTag -import com.keylesspalace.tusky.entity.Poll -import com.keylesspalace.tusky.entity.Status - -@Dao -abstract class TimelineDao { - - @Insert(onConflict = REPLACE) - abstract suspend fun insertAccount(timelineAccountEntity: TimelineAccountEntity): Long - - @Insert(onConflict = REPLACE) - abstract suspend fun insertStatus(timelineStatusEntity: TimelineStatusEntity): Long - - @Query( - """ -SELECT s.serverId, s.url, s.timelineUserId, -s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt, -s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, -s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId, -s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, s.filtered, -a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', -a.localUsername as 'a_localUsername', a.username as 'a_username', -a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', -a.emojis as 'a_emojis', a.bot as 'a_bot', -rb.serverId as 'rb_serverId', rb.timelineUserId 'rb_timelineUserId', -rb.localUsername as 'rb_localUsername', rb.username as 'rb_username', -rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar', -rb.emojis as 'rb_emojis', rb.bot as 'rb_bot' -FROM TimelineStatusEntity s -LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) -LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) -WHERE s.timelineUserId = :account -ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC""" - ) - abstract fun getStatuses(account: Long): PagingSource - - @Query( - """ -SELECT s.serverId, s.url, s.timelineUserId, -s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt, -s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, -s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId, -s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, s.filtered, -a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', -a.localUsername as 'a_localUsername', a.username as 'a_username', -a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', -a.emojis as 'a_emojis', a.bot as 'a_bot', -rb.serverId as 'rb_serverId', rb.timelineUserId 'rb_timelineUserId', -rb.localUsername as 'rb_localUsername', rb.username as 'rb_username', -rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar', -rb.emojis as 'rb_emojis', rb.bot as 'rb_bot' -FROM TimelineStatusEntity s -LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) -LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) -WHERE (s.serverId = :statusId OR s.reblogServerId = :statusId) -AND s.authorServerId IS NOT NULL -AND s.timelineUserId = :accountId""" - ) - abstract suspend fun getStatus(accountId: Long, statusId: String): TimelineStatusWithAccount? - - @Query( - """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND - (LENGTH(serverId) < LENGTH(:maxId) OR LENGTH(serverId) == LENGTH(:maxId) AND serverId <= :maxId) -AND -(LENGTH(serverId) > LENGTH(:minId) OR LENGTH(serverId) == LENGTH(:minId) AND serverId >= :minId) - """ - ) - abstract suspend fun deleteRange(accountId: Long, minId: String, maxId: String): Int - - suspend fun update(accountId: Long, status: Status) { - update( - accountId = accountId, - statusId = status.id, - content = status.content, - editedAt = status.editedAt?.time, - emojis = status.emojis, - reblogsCount = status.reblogsCount, - favouritesCount = status.favouritesCount, - repliesCount = status.repliesCount, - reblogged = status.reblogged, - bookmarked = status.bookmarked, - favourited = status.favourited, - sensitive = status.sensitive, - spoilerText = status.spoilerText, - visibility = status.visibility, - attachments = status.attachments, - mentions = status.mentions, - tags = status.tags, - poll = status.poll, - muted = status.muted, - pinned = status.pinned, - card = status.card, - language = status.language - ) - } - - @Query( - """UPDATE TimelineStatusEntity - SET content = :content, - editedAt = :editedAt, - emojis = :emojis, - reblogsCount = :reblogsCount, - favouritesCount = :favouritesCount, - repliesCount = :repliesCount, - reblogged = :reblogged, - bookmarked = :bookmarked, - favourited = :favourited, - sensitive = :sensitive, - spoilerText = :spoilerText, - visibility = :visibility, - attachments = :attachments, - mentions = :mentions, - tags = :tags, - poll = :poll, - muted = :muted, - pinned = :pinned, - card = :card, - language = :language - WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" - ) - @TypeConverters(Converters::class) - protected abstract suspend fun update( - accountId: Long, - statusId: String, - content: String?, - editedAt: Long?, - emojis: List, - reblogsCount: Int, - favouritesCount: Int, - repliesCount: Int, - reblogged: Boolean, - bookmarked: Boolean, - favourited: Boolean, - sensitive: Boolean, - spoilerText: String, - visibility: Status.Visibility, - attachments: List, - mentions: List, - tags: List?, - poll: Poll?, - muted: Boolean?, - pinned: Boolean, - card: Card?, - language: String? - ) - - @Query( - """UPDATE TimelineStatusEntity SET bookmarked = :bookmarked -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" - ) - abstract suspend fun setBookmarked(accountId: Long, statusId: String, bookmarked: Boolean) - - @Query( - """UPDATE TimelineStatusEntity SET reblogged = :reblogged -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" - ) - abstract suspend fun setReblogged(accountId: Long, statusId: String, reblogged: Boolean) - - @Query( - """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND -(authorServerId = :userId OR reblogAccountId = :userId)""" - ) - abstract suspend fun removeAllByUser(accountId: Long, userId: String) - - /** - * Removes everything in the TimelineStatusEntity and TimelineAccountEntity tables for one user account - * @param accountId id of the account for which to clean tables - */ - suspend fun removeAll(accountId: Long) { - removeAllStatuses(accountId) - removeAllAccounts(accountId) - } - - @Query("DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId") - abstract suspend fun removeAllStatuses(accountId: Long) - - @Query("DELETE FROM TimelineAccountEntity WHERE timelineUserId = :accountId") - abstract suspend fun removeAllAccounts(accountId: Long) - - @Query( - """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId -AND serverId = :statusId""" - ) - abstract suspend fun delete(accountId: Long, statusId: String) - - /** - * Cleans the TimelineStatusEntity and TimelineAccountEntity tables from old entries. - * @param accountId id of the account for which to clean tables - * @param limit how many statuses to keep - */ - suspend fun cleanup(accountId: Long, limit: Int) { - cleanupStatuses(accountId, limit) - cleanupAccounts(accountId) - } - - /** - * Cleans the TimelineStatusEntity table from old status entries. - * @param accountId id of the account for which to clean statuses - * @param limit how many statuses to keep - */ - @Query( - """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND serverId NOT IN - (SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :limit) - """ - ) - abstract suspend fun cleanupStatuses(accountId: Long, limit: Int) - - /** - * Cleans the TimelineAccountEntity table from accounts that are no longer referenced in the TimelineStatusEntity table - * @param accountId id of the user account for which to clean timeline accounts - */ - @Query( - """DELETE FROM TimelineAccountEntity WHERE timelineUserId = :accountId AND serverId NOT IN - (SELECT authorServerId FROM TimelineStatusEntity WHERE timelineUserId = :accountId) - AND serverId NOT IN - (SELECT reblogAccountId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND reblogAccountId IS NOT NULL)""" - ) - abstract suspend fun cleanupAccounts(accountId: Long) - - @Query( - """UPDATE TimelineStatusEntity SET poll = :poll -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" - ) - @TypeConverters(Converters::class) - abstract suspend fun setVoted(accountId: Long, statusId: String, poll: Poll) - - @Query( - """UPDATE TimelineStatusEntity SET expanded = :expanded -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" - ) - abstract suspend fun setExpanded(accountId: Long, statusId: String, expanded: Boolean) - - @Query( - """UPDATE TimelineStatusEntity SET contentShowing = :contentShowing -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" - ) - abstract suspend fun setContentShowing( - accountId: Long, - statusId: String, - contentShowing: Boolean - ) - - @Query( - """UPDATE TimelineStatusEntity SET contentCollapsed = :contentCollapsed -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" - ) - abstract suspend fun setContentCollapsed( - accountId: Long, - statusId: String, - contentCollapsed: Boolean - ) - - @Query( - """UPDATE TimelineStatusEntity SET pinned = :pinned -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" - ) - abstract suspend fun setPinned(accountId: Long, statusId: String, pinned: Boolean) - - @Query( - """DELETE FROM TimelineStatusEntity -WHERE timelineUserId = :accountId AND authorServerId IN ( -SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain -AND timelineUserId = :accountId -)""" - ) - abstract suspend fun deleteAllFromInstance(accountId: Long, instanceDomain: String) - - @Query( - "UPDATE TimelineStatusEntity SET filtered = NULL WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)" - ) - abstract suspend fun clearWarning(accountId: Long, statusId: String): Int - - @Query( - "SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1" - ) - abstract suspend fun getTopId(accountId: Long): String? - - @Query( - "SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1" - ) - abstract suspend fun getTopPlaceholderId(accountId: Long): String? - - /** - * Returns the id directly above [serverId], or null if [serverId] is the id of the top status - */ - @Query( - "SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (LENGTH(:serverId) < LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId < serverId)) ORDER BY LENGTH(serverId) ASC, serverId ASC LIMIT 1" - ) - abstract suspend fun getIdAbove(accountId: Long, serverId: String): String? - - /** - * Returns the ID directly below [serverId], or null if [serverId] is the ID of the bottom - * status - */ - @Query( - "SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1" - ) - abstract suspend fun getIdBelow(accountId: Long, serverId: String): String? - - /** - * Returns the id of the next placeholder after [serverId] - */ - @Query( - "SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1" - ) - abstract suspend fun getNextPlaceholderIdAfter(accountId: Long, serverId: String): String? - - @Query("SELECT COUNT(*) FROM TimelineStatusEntity WHERE timelineUserId = :accountId") - abstract suspend fun getStatusCount(accountId: Long): Int - - /** Developer tools: Find N most recent status IDs */ - @Query( - "SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :count" - ) - abstract suspend fun getMostRecentNStatusIds(accountId: Long, count: Int): List - - /** Developer tools: Convert a status to a placeholder */ - @Query("UPDATE TimelineStatusEntity SET authorServerId = NULL WHERE serverId = :serverId") - abstract suspend fun convertStatustoPlaceholder(serverId: String) -} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/AccountDao.kt similarity index 72% rename from app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt rename to app/src/main/java/com/keylesspalace/tusky/db/dao/AccountDao.kt index 218c9b8f4..eb38316ca 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/AccountDao.kt @@ -13,22 +13,24 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.db +package com.keylesspalace.tusky.db.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import com.keylesspalace.tusky.db.entity.AccountEntity +import kotlinx.coroutines.flow.Flow @Dao interface AccountDao { @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertOrReplace(account: AccountEntity): Long + suspend fun insertOrReplace(account: AccountEntity): Long @Delete - fun delete(account: AccountEntity) + suspend fun delete(account: AccountEntity) - @Query("SELECT * FROM AccountEntity ORDER BY id ASC") - fun loadAll(): List + @Query("SELECT * FROM AccountEntity ORDER BY isActive DESC") + fun allAccounts(): Flow> } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/DraftDao.kt similarity index 95% rename from app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt rename to app/src/main/java/com/keylesspalace/tusky/db/dao/DraftDao.kt index 4d7239d02..c0f80aa9f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/DraftDao.kt @@ -13,13 +13,14 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.db +package com.keylesspalace.tusky.db.dao import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import com.keylesspalace.tusky.db.entity.DraftEntity import kotlinx.coroutines.flow.Flow @Dao diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/InstanceDao.kt similarity index 72% rename from app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt rename to app/src/main/java/com/keylesspalace/tusky/db/dao/InstanceDao.kt index 317c577ec..b39c15b0b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/InstanceDao.kt @@ -13,12 +13,15 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.db +package com.keylesspalace.tusky.db.dao import androidx.room.Dao import androidx.room.Query import androidx.room.RewriteQueriesToDropUnusedColumns import androidx.room.Upsert +import com.keylesspalace.tusky.db.entity.EmojisEntity +import com.keylesspalace.tusky.db.entity.InstanceEntity +import com.keylesspalace.tusky.db.entity.InstanceInfoEntity @Dao interface InstanceDao { @@ -36,4 +39,10 @@ interface InstanceDao { @RewriteQueriesToDropUnusedColumns @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") suspend fun getEmojiInfo(instance: String): EmojisEntity? + + @Query("UPDATE InstanceEntity SET filterV2Supported = :filterV2Support WHERE instance = :instance") + suspend fun setFilterV2Support(instance: String, filterV2Support: Boolean) + + @Query("SELECT filterV2Supported FROM InstanceEntity WHERE instance = :instance LIMIT 1") + suspend fun getFilterV2Support(instance: String): Boolean } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/dao/NotificationPolicyDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/NotificationPolicyDao.kt new file mode 100644 index 000000000..7fb2381f0 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/NotificationPolicyDao.kt @@ -0,0 +1,43 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy.Companion.REPLACE +import androidx.room.Query +import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface NotificationPolicyDao { + @Query("SELECT * FROM NotificationPolicyEntity WHERE tuskyAccountId = :accountId") + fun notificationPolicyForAccount(accountId: Long): Flow + + @Insert(onConflict = REPLACE) + suspend fun update(entity: NotificationPolicyEntity) + + @Query( + "UPDATE NotificationPolicyEntity " + + "SET pendingRequestsCount = max(0, pendingRequestsCount - 1)," + + "pendingNotificationsCount = max(0, pendingNotificationsCount - :notificationCount) " + + "WHERE tuskyAccountId = :accountId" + ) + suspend fun updateCounts( + accountId: Long, + notificationCount: Int + ) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/dao/NotificationsDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/NotificationsDao.kt new file mode 100644 index 000000000..0c3929c77 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/NotificationsDao.kt @@ -0,0 +1,175 @@ +/* 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.db.dao + +import androidx.paging.PagingSource +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy.Companion.REPLACE +import androidx.room.Query +import com.keylesspalace.tusky.db.entity.NotificationDataEntity +import com.keylesspalace.tusky.db.entity.NotificationEntity +import com.keylesspalace.tusky.db.entity.NotificationReportEntity + +@Dao +abstract class NotificationsDao { + + @Insert(onConflict = REPLACE) + abstract suspend fun insertNotification(notificationEntity: NotificationEntity): Long + + @Insert(onConflict = REPLACE) + abstract suspend fun insertReport(notificationReportDataEntity: NotificationReportEntity): Long + + @Query( + """ +SELECT n.tuskyAccountId, n.type, n.id, n.loading, n.event, n.moderationWarning, +a.serverId as 'a_serverId', a.tuskyAccountId as 'a_tuskyAccountId', +a.localUsername as 'a_localUsername', a.username as 'a_username', +a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', +a.note as 'a_note', a.emojis as 'a_emojis', a.bot as 'a_bot', +s.serverId as 's_serverId', s.url as 's_url', s.tuskyAccountId as 's_tuskyAccountId', +s.authorServerId as 's_authorServerId', s.inReplyToId as 's_inReplyToId', s.inReplyToAccountId as 's_inReplyToAccountId', +s.content as 's_content', s.createdAt as 's_createdAt', s.editedAt as 's_editedAt', s.emojis as 's_emojis', s.reblogsCount as 's_reblogsCount', +s.favouritesCount as 's_favouritesCount', s.repliesCount as 's_repliesCount', s.reblogged as 's_reblogged', s.favourited as 's_favourited', +s.bookmarked as 's_bookmarked', s.sensitive as 's_sensitive', s.spoilerText as 's_spoilerText', s.visibility as 's_visibility', +s.mentions as 's_mentions', s.tags as 's_tags', s.application as 's_application', s.content as 's_content', s.attachments as 's_attachments', s.poll as 's_poll', +s.card as 's_card', s.muted as 's_muted', s.expanded as 's_expanded', s.contentShowing as 's_contentShowing', s.contentCollapsed as 's_contentCollapsed', +s.pinned as 's_pinned', s.language as 's_language', s.filtered as 's_filtered', +sa.serverId as 'sa_serverId', sa.tuskyAccountId as 'sa_tuskyAccountId', +sa.localUsername as 'sa_localUsername', sa.username as 'sa_username', +sa.displayName as 'sa_displayName', sa.url as 'sa_url', sa.avatar as 'sa_avatar', +sa.note as 'sa_note', sa.emojis as 'sa_emojis', sa.bot as 'sa_bot', +r.serverId as 'r_serverId', r.tuskyAccountId as 'r_tuskyAccountId', +r.category as 'r_category', r.statusIds as 'r_statusIds', +r.createdAt as 'r_createdAt', r.targetAccountId as 'r_targetAccountId', +ra.serverId as 'ra_serverId', ra.tuskyAccountId as 'ra_tuskyAccountId', +ra.localUsername as 'ra_localUsername', ra.username as 'ra_username', +ra.displayName as 'ra_displayName', ra.url as 'ra_url', ra.avatar as 'ra_avatar', +ra.note as 'ra_note', ra.emojis as 'ra_emojis', ra.bot as 'ra_bot' +FROM NotificationEntity n +LEFT JOIN TimelineAccountEntity a ON (n.tuskyAccountId = a.tuskyAccountId AND n.accountId = a.serverId) +LEFT JOIN TimelineStatusEntity s ON (n.tuskyAccountId = s.tuskyAccountId AND n.statusId = s.serverId) +LEFT JOIN TimelineAccountEntity sa ON (n.tuskyAccountId = sa.tuskyAccountId AND s.authorServerId = sa.serverId) +LEFT JOIN NotificationReportEntity r ON (n.tuskyAccountId = r.tuskyAccountId AND n.reportId = r.serverId) +LEFT JOIN TimelineAccountEntity ra ON (n.tuskyAccountId = ra.tuskyAccountId AND r.targetAccountId = ra.serverId) +WHERE n.tuskyAccountId = :tuskyAccountId +ORDER BY LENGTH(n.id) DESC, n.id DESC""" + ) + abstract fun getNotifications(tuskyAccountId: Long): PagingSource + + @Query( + """DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND id = :notificationId""" + ) + abstract suspend fun delete(tuskyAccountId: Long, notificationId: String): Int + + @Query( + """DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND + (LENGTH(id) < LENGTH(:maxId) OR LENGTH(id) == LENGTH(:maxId) AND id <= :maxId) +AND +(LENGTH(id) > LENGTH(:minId) OR LENGTH(id) == LENGTH(:minId) AND id >= :minId) + """ + ) + abstract suspend fun deleteRange(tuskyAccountId: Long, minId: String, maxId: String): Int + + @Query( + """DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId""" + ) + internal abstract suspend fun removeAllNotifications(tuskyAccountId: Long) + + /** + * Deletes all NotificationReportEntities for Tusky user with id [tuskyAccountId]. + * Warning: This can violate foreign key constraints if reports are still referenced in the NotificationEntity table. + */ + @Query( + """DELETE FROM NotificationReportEntity WHERE tuskyAccountId = :tuskyAccountId""" + ) + internal abstract suspend fun removeAllReports(tuskyAccountId: Long) + + @Query( + """DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId = :statusId""" + ) + abstract suspend fun deleteAllWithStatus(tuskyAccountId: Long, statusId: String) + + /** + * Remove all notifications from user with id [userId] unless they are admin notifications. + */ + @Query( + """DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND + (accountId = :userId OR + statusId IN (SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId = :userId) + ) + AND type != "admin.sign_up" AND type != "admin.report" + """ + ) + abstract suspend fun removeAllByUser(tuskyAccountId: Long, userId: String) + + @Query( + """DELETE FROM NotificationEntity + WHERE tuskyAccountId = :tuskyAccountId AND statusId IN ( + SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId in + ( SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain + AND tuskyAccountId = :tuskyAccountId) + OR accountId IN ( SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain + AND tuskyAccountId = :tuskyAccountId) + )""" + ) + abstract suspend fun deleteAllFromInstance(tuskyAccountId: Long, instanceDomain: String) + + @Query("SELECT id FROM NotificationEntity WHERE tuskyAccountId = :accountId ORDER BY LENGTH(id) DESC, id DESC LIMIT 1") + abstract suspend fun getTopId(accountId: Long): String? + + @Query("SELECT id FROM NotificationEntity WHERE tuskyAccountId = :accountId AND type IS NULL ORDER BY LENGTH(id) DESC, id DESC LIMIT 1") + abstract suspend fun getTopPlaceholderId(accountId: Long): String? + + /** + * Cleans the NotificationEntity table from old entries. + * @param tuskyAccountId id of the account for which to clean tables + * @param limit how many timeline items to keep + */ + @Query( + """DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND id NOT IN + (SELECT id FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT :limit) + """ + ) + internal abstract suspend fun cleanupNotifications(tuskyAccountId: Long, limit: Int) + + /** + * Cleans the NotificationReportEntity table from unreferenced entries. + * @param tuskyAccountId id of the account for which to clean the table + */ + @Query( + """DELETE FROM NotificationReportEntity WHERE tuskyAccountId = :tuskyAccountId + AND serverId NOT IN + (SELECT reportId FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId and reportId IS NOT NULL)""" + ) + internal abstract suspend fun cleanupReports(tuskyAccountId: Long) + + /** + * Returns the id directly above [id], or null if [id] is the id of the top item + */ + @Query( + "SELECT id FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) < LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id < id)) ORDER BY LENGTH(id) ASC, id ASC LIMIT 1" + ) + abstract suspend fun getIdAbove(tuskyAccountId: Long, id: String): String? + + /** + * Returns the ID directly below [id], or null if [id] is the ID of the bottom item + */ + @Query( + "SELECT id FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) > LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id > id)) ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" + ) + abstract suspend fun getIdBelow(tuskyAccountId: Long, id: String): String? +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineAccountDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineAccountDao.kt new file mode 100644 index 000000000..700063e00 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineAccountDao.kt @@ -0,0 +1,56 @@ +/* Copyright 2024 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.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy.Companion.REPLACE +import androidx.room.Query +import com.keylesspalace.tusky.db.entity.TimelineAccountEntity + +@Dao +abstract class TimelineAccountDao { + + @Insert(onConflict = REPLACE) + abstract suspend fun insert(timelineAccountEntity: TimelineAccountEntity): Long + + @Query( + """SELECT * FROM TimelineAccountEntity a + WHERE a.serverId = :accountId + AND a.tuskyAccountId = :tuskyAccountId""" + ) + internal abstract suspend fun getAccount(tuskyAccountId: Long, accountId: String): TimelineAccountEntity? + + @Query("DELETE FROM TimelineAccountEntity WHERE tuskyAccountId = :tuskyAccountId") + abstract suspend fun removeAllAccounts(tuskyAccountId: Long) + + /** + * Cleans the TimelineAccountEntity table from accounts that are no longer referenced by either TimelineStatusEntity, HomeTimelineEntity or NotificationEntity + * @param tuskyAccountId id of the user account for which to clean timeline accounts + */ + @Query( + """DELETE FROM TimelineAccountEntity WHERE tuskyAccountId = :tuskyAccountId + AND serverId NOT IN + (SELECT authorServerId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId) + AND serverId NOT IN + (SELECT reblogAccountId FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND reblogAccountId IS NOT NULL) + AND serverId NOT IN + (SELECT accountId FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND accountId IS NOT NULL) + AND serverId NOT IN + (SELECT targetAccountId FROM NotificationReportEntity WHERE tuskyAccountId = :tuskyAccountId AND targetAccountId IS NOT NULL)""" + ) + abstract suspend fun cleanupAccounts(tuskyAccountId: Long) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineDao.kt new file mode 100644 index 000000000..6107e73d4 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineDao.kt @@ -0,0 +1,174 @@ +/* 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.db.dao + +import androidx.paging.PagingSource +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy.Companion.REPLACE +import androidx.room.Query +import com.keylesspalace.tusky.db.entity.HomeTimelineData +import com.keylesspalace.tusky.db.entity.HomeTimelineEntity + +@Dao +abstract class TimelineDao { + + @Insert(onConflict = REPLACE) + abstract suspend fun insertHomeTimelineItem(item: HomeTimelineEntity): Long + + @Query( + """ +SELECT h.id, s.serverId, s.url, s.tuskyAccountId, +s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt, +s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, +s.spoilerText, s.visibility, s.mentions, s.tags, s.application, +s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, s.filtered, +a.serverId as 'a_serverId', a.tuskyAccountId as 'a_tuskyAccountId', +a.localUsername as 'a_localUsername', a.username as 'a_username', +a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', +a.note as 'a_note', a.emojis as 'a_emojis', a.bot as 'a_bot', +rb.serverId as 'rb_serverId', rb.tuskyAccountId 'rb_tuskyAccountId', +rb.localUsername as 'rb_localUsername', rb.username as 'rb_username', +rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar', +rb.note as 'rb_note', rb.emojis as 'rb_emojis', rb.bot as 'rb_bot', +replied.serverId as 'replied_serverId', replied.tuskyAccountId 'replied_tuskyAccountId', +replied.localUsername as 'replied_localUsername', replied.username as 'replied_username', +replied.displayName as 'replied_displayName', replied.url as 'replied_url', replied.avatar as 'replied_avatar', +replied.note as 'replied_note', replied.emojis as 'replied_emojis', replied.bot as 'replied_bot', +h.loading +FROM HomeTimelineEntity h +LEFT JOIN TimelineStatusEntity s ON (h.statusId = s.serverId AND s.tuskyAccountId = :tuskyAccountId) +LEFT JOIN TimelineAccountEntity a ON (s.authorServerId = a.serverId AND a.tuskyAccountId = :tuskyAccountId) +LEFT JOIN TimelineAccountEntity rb ON (h.reblogAccountId = rb.serverId AND rb.tuskyAccountId = :tuskyAccountId) +LEFT JOIN TimelineAccountEntity replied ON (s.inReplyToAccountId = replied.serverId AND replied.tuskyAccountId = :tuskyAccountId) +WHERE h.tuskyAccountId = :tuskyAccountId +ORDER BY LENGTH(h.id) DESC, h.id DESC""" + ) + abstract fun getHomeTimeline(tuskyAccountId: Long): PagingSource + + @Query( + """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND + (LENGTH(id) < LENGTH(:maxId) OR LENGTH(id) == LENGTH(:maxId) AND id <= :maxId) +AND +(LENGTH(id) > LENGTH(:minId) OR LENGTH(id) == LENGTH(:minId) AND id >= :minId) + """ + ) + abstract suspend fun deleteRange(tuskyAccountId: Long, minId: String, maxId: String): Int + + /** + * Remove all home timeline items that are statuses or reblogs by the user with id [userId], including reblogs from other people. + * (e.g. because user was blocked) + */ + @Query( + """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND + (statusId IN + (SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId == :userId) + OR reblogAccountId == :userId) + """ + ) + abstract suspend fun removeAllByUser(tuskyAccountId: Long, userId: String) + + /** + * Remove all home timeline items that are statuses or reblogs by the user with id [userId], but not reblogs from other users. + * (e.g. because user was unfollowed) + */ + @Query( + """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND + ((statusId IN + (SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId == :userId) + AND reblogAccountId IS NULL) + OR reblogAccountId == :userId) + """ + ) + abstract suspend fun removeStatusesAndReblogsByUser(tuskyAccountId: Long, userId: String) + + @Query("DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId") + abstract suspend fun removeAllHomeTimelineItems(tuskyAccountId: Long) + + @Query( + """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND id = :id""" + ) + abstract suspend fun deleteHomeTimelineItem(tuskyAccountId: Long, id: String) + + /** + * Deletes all hometimeline items that reference the status with it [statusId]. They can be regular statuses or reblogs. + */ + @Query( + """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId = :statusId""" + ) + abstract suspend fun deleteAllWithStatus(tuskyAccountId: Long, statusId: String) + + /** + * Trims the HomeTimelineEntity table down to [limit] entries by deleting the oldest in case there are more than [limit]. + * @param tuskyAccountId id of the account for which to clean the home timeline + * @param limit how many timeline items to keep + */ + @Query( + """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND id NOT IN + (SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT :limit) + """ + ) + internal abstract suspend fun cleanupHomeTimeline(tuskyAccountId: Long, limit: Int) + + @Query( + """DELETE FROM HomeTimelineEntity +WHERE tuskyAccountId = :tuskyAccountId AND statusId IN ( +SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId in +( SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain +AND tuskyAccountId = :tuskyAccountId +))""" + ) + abstract suspend fun deleteAllFromInstance(tuskyAccountId: Long, instanceDomain: String) + + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" + ) + abstract suspend fun getTopId(tuskyAccountId: Long): String? + + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IS NULL ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" + ) + abstract suspend fun getTopPlaceholderId(tuskyAccountId: Long): String? + + /** + * Returns the id directly above [id], or null if [id] is the id of the top item + */ + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) < LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id < id)) ORDER BY LENGTH(id) ASC, id ASC LIMIT 1" + ) + abstract suspend fun getIdAbove(tuskyAccountId: Long, id: String): String? + + /** + * Returns the ID directly below [id], or null if [id] is the ID of the bottom item + */ + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) > LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id > id)) ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" + ) + abstract suspend fun getIdBelow(tuskyAccountId: Long, id: String): String? + + @Query("SELECT COUNT(*) FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId") + abstract suspend fun getHomeTimelineItemCount(tuskyAccountId: Long): Int + + /** Developer tools: Find N most recent status IDs */ + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT :count" + ) + abstract suspend fun getMostRecentNHomeTimelineIds(tuskyAccountId: Long, count: Int): List + + /** Developer tools: Convert a home timeline item to a placeholder */ + @Query("UPDATE HomeTimelineEntity SET statusId = NULL, reblogAccountId = NULL WHERE id = :serverId") + abstract suspend fun convertHomeTimelineItemToPlaceholder(serverId: String) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineStatusDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineStatusDao.kt new file mode 100644 index 000000000..66c759711 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineStatusDao.kt @@ -0,0 +1,279 @@ +/* Copyright 2024 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.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy.Companion.REPLACE +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.TypeConverters +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.Converters +import com.keylesspalace.tusky.db.entity.TimelineAccountEntity +import com.keylesspalace.tusky.db.entity.TimelineStatusEntity +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.HashTag +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.PreviewCard +import com.keylesspalace.tusky.entity.Status +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter + +@Dao +abstract class TimelineStatusDao( + private val db: AppDatabase +) { + + @Insert(onConflict = REPLACE) + abstract suspend fun insert(timelineStatusEntity: TimelineStatusEntity): Long + + @Transaction + open suspend fun getStatusWithAccount(tuskyAccountId: Long, statusId: String): Pair? { + val status = getStatus(tuskyAccountId, statusId) ?: return null + val account = db.timelineAccountDao().getAccount(tuskyAccountId, status.authorServerId) ?: return null + return status to account + } + + @Query( + """ +SELECT * FROM TimelineStatusEntity s +WHERE s.serverId = :statusId +AND s.authorServerId IS NOT NULL +AND s.tuskyAccountId = :tuskyAccountId""" + ) + abstract suspend fun getStatus(tuskyAccountId: Long, statusId: String): TimelineStatusEntity? + + @OptIn(ExperimentalStdlibApi::class) + suspend fun update(tuskyAccountId: Long, status: Status, moshi: Moshi) { + update( + tuskyAccountId = tuskyAccountId, + statusId = status.id, + content = status.content, + editedAt = status.editedAt?.time, + emojis = moshi.adapter?>().toJson(status.emojis), + reblogsCount = status.reblogsCount, + favouritesCount = status.favouritesCount, + repliesCount = status.repliesCount, + reblogged = status.reblogged, + bookmarked = status.bookmarked, + favourited = status.favourited, + sensitive = status.sensitive, + spoilerText = status.spoilerText, + visibility = status.visibility, + attachments = moshi.adapter?>().toJson(status.attachments), + mentions = moshi.adapter?>().toJson(status.mentions), + tags = moshi.adapter?>().toJson(status.tags), + poll = moshi.adapter().toJson(status.poll), + muted = status.muted, + pinned = status.pinned, + card = moshi.adapter().toJson(status.card), + language = status.language + ) + } + + @Query( + """UPDATE TimelineStatusEntity + SET content = :content, + editedAt = :editedAt, + emojis = :emojis, + reblogsCount = :reblogsCount, + favouritesCount = :favouritesCount, + repliesCount = :repliesCount, + reblogged = :reblogged, + bookmarked = :bookmarked, + favourited = :favourited, + sensitive = :sensitive, + spoilerText = :spoilerText, + visibility = :visibility, + attachments = :attachments, + mentions = :mentions, + tags = :tags, + poll = :poll, + muted = :muted, + pinned = :pinned, + card = :card, + language = :language + WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" + ) + @TypeConverters(Converters::class) + abstract suspend fun update( + tuskyAccountId: Long, + statusId: String, + content: String?, + editedAt: Long?, + emojis: String?, + reblogsCount: Int, + favouritesCount: Int, + repliesCount: Int, + reblogged: Boolean, + bookmarked: Boolean, + favourited: Boolean, + sensitive: Boolean, + spoilerText: String, + visibility: Status.Visibility, + attachments: String?, + mentions: String?, + tags: String?, + poll: String?, + muted: Boolean?, + pinned: Boolean, + card: String?, + language: String? + ) + + @Query( + """UPDATE TimelineStatusEntity SET bookmarked = :bookmarked +WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" + ) + abstract suspend fun setBookmarked(tuskyAccountId: Long, statusId: String, bookmarked: Boolean) + + @Query( + """UPDATE TimelineStatusEntity SET reblogged = :reblogged +WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" + ) + abstract suspend fun setReblogged(tuskyAccountId: Long, statusId: String, reblogged: Boolean) + + @Query("DELETE FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId") + abstract suspend fun removeAllStatuses(tuskyAccountId: Long) + + @Query( + """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND id = :id""" + ) + abstract suspend fun deleteHomeTimelineItem(tuskyAccountId: Long, id: String) + + /** + * Deletes all hometimeline items that reference the status with it [statusId]. They can be regular statuses or reblogs. + */ + @Query( + """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId = :statusId""" + ) + abstract suspend fun deleteAllWithStatus(tuskyAccountId: Long, statusId: String) + + /** + * Cleans the TimelineStatusEntity table from unreferenced status entries. + * @param tuskyAccountId id of the account for which to clean statuses + */ + @Query( + """DELETE FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId + AND serverId NOT IN + (SELECT statusId FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IS NOT NULL) + AND serverId NOT IN + (SELECT statusId FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IS NOT NULL)""" + ) + internal abstract suspend fun cleanupStatuses(tuskyAccountId: Long) + + @Query( + """UPDATE TimelineStatusEntity SET poll = :poll +WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" + ) + abstract suspend fun setVoted(tuskyAccountId: Long, statusId: String, poll: String) + + @Query( + """UPDATE TimelineStatusEntity SET expanded = :expanded +WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" + ) + abstract suspend fun setExpanded(tuskyAccountId: Long, statusId: String, expanded: Boolean) + + @Query( + """UPDATE TimelineStatusEntity SET contentShowing = :contentShowing +WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" + ) + abstract suspend fun setContentShowing( + tuskyAccountId: Long, + statusId: String, + contentShowing: Boolean + ) + + @Query( + """UPDATE TimelineStatusEntity SET contentCollapsed = :contentCollapsed +WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" + ) + abstract suspend fun setContentCollapsed( + tuskyAccountId: Long, + statusId: String, + contentCollapsed: Boolean + ) + + @Query( + """UPDATE TimelineStatusEntity SET pinned = :pinned +WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" + ) + abstract suspend fun setPinned(tuskyAccountId: Long, statusId: String, pinned: Boolean) + + @Query( + """DELETE FROM HomeTimelineEntity +WHERE tuskyAccountId = :tuskyAccountId AND statusId IN ( +SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId in +( SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain +AND tuskyAccountId = :tuskyAccountId +))""" + ) + abstract suspend fun deleteAllFromInstance(tuskyAccountId: Long, instanceDomain: String) + + @Query( + "UPDATE TimelineStatusEntity SET filtered = '[]' WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId" + ) + abstract suspend fun clearWarning(tuskyAccountId: Long, statusId: String): Int + + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" + ) + abstract suspend fun getTopId(tuskyAccountId: Long): String? + + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IS NULL ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" + ) + abstract suspend fun getTopPlaceholderId(tuskyAccountId: Long): String? + + /** + * Returns the id directly above [id], or null if [id] is the id of the top item + */ + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) < LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id < id)) ORDER BY LENGTH(id) ASC, id ASC LIMIT 1" + ) + abstract suspend fun getIdAbove(tuskyAccountId: Long, id: String): String? + + /** + * Returns the ID directly below [id], or null if [id] is the ID of the bottom item + */ + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) > LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id > id)) ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" + ) + abstract suspend fun getIdBelow(tuskyAccountId: Long, id: String): String? + + /** + * Returns the id of the next placeholder after [id], or null if there is no placeholder. + */ + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IS NULL AND (LENGTH(:id) > LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id > id)) ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" + ) + abstract suspend fun getNextPlaceholderIdAfter(tuskyAccountId: Long, id: String): String? + + @Query("SELECT COUNT(*) FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId") + abstract suspend fun getHomeTimelineItemCount(tuskyAccountId: Long): Int + + /** Developer tools: Find N most recent status IDs */ + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT :count" + ) + abstract suspend fun getMostRecentNStatusIds(tuskyAccountId: Long, count: Int): List + + /** Developer tools: Convert a home timeline item to a placeholder */ + @Query("UPDATE HomeTimelineEntity SET statusId = NULL, reblogAccountId = NULL WHERE id = :serverId") + abstract suspend fun convertStatusToPlaceholder(serverId: String) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt new file mode 100644 index 000000000..da8fe0b0a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt @@ -0,0 +1,137 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import com.keylesspalace.tusky.TabData +import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData +import com.keylesspalace.tusky.db.Converters +import com.keylesspalace.tusky.defaultTabs +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.settings.DefaultReplyVisibility + +@Entity( + indices = [ + Index( + value = ["domain", "accountId"], + unique = true + ) + ] +) +@TypeConverters(Converters::class) +data class AccountEntity( + @field:PrimaryKey(autoGenerate = true) val id: Long, + val domain: String, + val accessToken: String, + // nullable for backward compatibility + val clientId: String?, + // nullable for backward compatibility + val clientSecret: String?, + val isActive: Boolean, + val accountId: String = "", + val username: String = "", + val displayName: String = "", + val profilePictureUrl: String = "", + @ColumnInfo(defaultValue = "") val profileHeaderUrl: String = "", + val notificationsEnabled: Boolean = true, + val notificationsMentioned: Boolean = true, + val notificationsFollowed: Boolean = true, + val notificationsFollowRequested: Boolean = true, + val notificationsReblogged: Boolean = true, + val notificationsFavorited: Boolean = true, + val notificationsPolls: Boolean = true, + val notificationsSubscriptions: Boolean = true, + val notificationsUpdates: Boolean = true, + @ColumnInfo(defaultValue = "true") val notificationsAdmin: Boolean = true, + @ColumnInfo(defaultValue = "true") val notificationsOther: Boolean = true, + val notificationSound: Boolean = true, + val notificationVibration: Boolean = true, + val notificationLight: Boolean = true, + val defaultPostPrivacy: Status.Visibility = Status.Visibility.PUBLIC, + val defaultReplyPrivacy: DefaultReplyVisibility = DefaultReplyVisibility.MATCH_DEFAULT_POST_VISIBILITY, + val defaultMediaSensitivity: Boolean = false, + val defaultPostLanguage: String = "", + val alwaysShowSensitiveMedia: Boolean = false, + /** True if content behind a content warning is shown by default */ + @ColumnInfo(defaultValue = "0") + val alwaysOpenSpoiler: Boolean = false, + + /** + * True if the "Download media previews" preference is true. This implies + * that media previews are shown as well as downloaded. + */ + val mediaPreviewEnabled: Boolean = true, + /** + * ID of the last notification the user read on the Notification, list, and should be restored + * to view when the user returns to the list. + * + * May not be the ID of the most recent notification if the user has scrolled down the list. + */ + val lastNotificationId: String = "0", + /** + * ID of the most recent Mastodon notification that Tusky has fetched to show as an + * Android notification. + */ + @ColumnInfo(defaultValue = "0") + val notificationMarkerId: String = "0", + val emojis: List = emptyList(), + val tabPreferences: List = defaultTabs(), + val notificationsFilter: Set = emptySet(), + // Scope cannot be changed without re-login, so store it in case + // the scope needs to be changed in the future + val oauthScopes: String = "", + val unifiedPushUrl: String = "", + val pushPubKey: String = "", + val pushPrivKey: String = "", + val pushAuth: String = "", + val pushServerKey: String = "", + + /** + * ID of the status at the top of the visible list in the home timeline when the + * user navigated away. + */ + val lastVisibleHomeTimelineStatusId: String? = null, + + /** true if the connected Mastodon account is locked (has to manually approve all follow requests **/ + @ColumnInfo(defaultValue = "0") + val locked: Boolean = false, + + @ColumnInfo(defaultValue = "0") + val hasDirectMessageBadge: Boolean = false, + + val isShowHomeBoosts: Boolean = true, + val isShowHomeReplies: Boolean = true, + val isShowHomeSelfBoosts: Boolean = true +) { + val identifier: String + get() = "$domain:$accountId" + + val fullName: String + get() = "@$username@$domain" + + fun isPushNotificationsEnabled(): Boolean { + return unifiedPushUrl.isNotEmpty() + } + + fun matchesPushSubscription(endpoint: String): Boolean { + return unifiedPushUrl == endpoint + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/DraftEntity.kt similarity index 95% rename from app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt rename to app/src/main/java/com/keylesspalace/tusky/db/entity/DraftEntity.kt index a5928ca0b..18423438f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/DraftEntity.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.db +package com.keylesspalace.tusky.db.entity import android.net.Uri import android.os.Parcelable @@ -21,6 +21,7 @@ import androidx.core.net.toUri import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverters +import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status diff --git a/app/src/main/java/com/keylesspalace/tusky/db/entity/HomeTimelineEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/HomeTimelineEntity.kt new file mode 100644 index 000000000..35e449332 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/HomeTimelineEntity.kt @@ -0,0 +1,69 @@ +/* Copyright 2024 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.db.entity + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index + +/** + * Entity to store an item on the home timeline. Can be a standalone status, a reblog, or a placeholder. + */ +@Entity( + primaryKeys = ["id", "tuskyAccountId"], + foreignKeys = ( + [ + ForeignKey( + entity = TimelineStatusEntity::class, + parentColumns = ["serverId", "tuskyAccountId"], + childColumns = ["statusId", "tuskyAccountId"] + ), + ForeignKey( + entity = TimelineAccountEntity::class, + parentColumns = ["serverId", "tuskyAccountId"], + childColumns = ["reblogAccountId", "tuskyAccountId"] + ) + ] + ), + indices = [ + Index("statusId", "tuskyAccountId"), + Index("reblogAccountId", "tuskyAccountId"), + ] +) +data class HomeTimelineEntity( + val tuskyAccountId: Long, + // the id by which the timeline is sorted + val id: String, + // the id of the status, null when a placeholder + val statusId: String?, + // the id of the account who reblogged the status, null if no reblog + val reblogAccountId: String?, + // only relevant when this is a placeholder + val loading: Boolean = false +) + +/** + * Helper class for queries that return HomeTimelineEntity including all references + */ +data class HomeTimelineData( + val id: String, + @Embedded val status: TimelineStatusEntity?, + @Embedded(prefix = "a_") val account: TimelineAccountEntity?, + @Embedded(prefix = "rb_") val reblogAccount: TimelineAccountEntity?, + @Embedded(prefix = "replied_") val repliedToAccount: TimelineAccountEntity?, + val loading: Boolean +) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/InstanceEntity.kt similarity index 89% rename from app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt rename to app/src/main/java/com/keylesspalace/tusky/db/entity/InstanceEntity.kt index 4db2ee050..aac38217c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/InstanceEntity.kt @@ -13,11 +13,13 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.db +package com.keylesspalace.tusky.db.entity +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverters +import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.entity.Emoji @Entity @@ -40,6 +42,8 @@ data class InstanceEntity( val maxFieldNameLength: Int?, val maxFieldValueLength: Int?, val translationEnabled: Boolean?, + // ToDo: Remove this again when filter v1 support is dropped + @ColumnInfo(defaultValue = "false") val filterV2Supported: Boolean = false ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/entity/NotificationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/NotificationEntity.kt new file mode 100644 index 000000000..05c0de606 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/NotificationEntity.kt @@ -0,0 +1,114 @@ +/* Copyright 2024 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.db.entity + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.TypeConverters +import com.keylesspalace.tusky.db.Converters +import com.keylesspalace.tusky.entity.AccountWarning +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.RelationshipSeveranceEvent +import java.util.Date + +@TypeConverters(Converters::class) +data class NotificationDataEntity( + // id of the account logged into Tusky this notifications belongs to + val tuskyAccountId: Long, + // null when placeholder + val type: Notification.Type?, + val id: String, + @Embedded(prefix = "a_") val account: TimelineAccountEntity?, + @Embedded(prefix = "s_") val status: TimelineStatusEntity?, + @Embedded(prefix = "sa_") val statusAccount: TimelineAccountEntity?, + @Embedded(prefix = "r_") val report: NotificationReportEntity?, + @Embedded(prefix = "ra_") val reportTargetAccount: TimelineAccountEntity?, + val event: RelationshipSeveranceEvent?, + val moderationWarning: AccountWarning?, + // relevant when it is a placeholder + val loading: Boolean = false +) + +@Entity( + primaryKeys = ["id", "tuskyAccountId"], + foreignKeys = ( + [ + ForeignKey( + entity = TimelineAccountEntity::class, + parentColumns = ["serverId", "tuskyAccountId"], + childColumns = ["accountId", "tuskyAccountId"] + ), + ForeignKey( + entity = TimelineStatusEntity::class, + parentColumns = ["serverId", "tuskyAccountId"], + childColumns = ["statusId", "tuskyAccountId"] + ), + ForeignKey( + entity = NotificationReportEntity::class, + parentColumns = ["serverId", "tuskyAccountId"], + childColumns = ["reportId", "tuskyAccountId"] + ) + ] + ), + indices = [ + Index("accountId", "tuskyAccountId"), + Index("statusId", "tuskyAccountId"), + Index("reportId", "tuskyAccountId"), + ] +) +@TypeConverters(Converters::class) +data class NotificationEntity( + // id of the account logged into Tusky this notifications belongs to + val tuskyAccountId: Long, + // null when placeholder + val type: Notification.Type?, + val id: String, + val accountId: String?, + val statusId: String?, + val reportId: String?, + val event: RelationshipSeveranceEvent?, + val moderationWarning: AccountWarning?, + // relevant when it is a placeholder + val loading: Boolean = false +) + +@Entity( + primaryKeys = ["serverId", "tuskyAccountId"], + foreignKeys = ( + [ + ForeignKey( + entity = TimelineAccountEntity::class, + parentColumns = ["serverId", "tuskyAccountId"], + childColumns = ["targetAccountId", "tuskyAccountId"] + ) + ] + ), + indices = [ + Index("targetAccountId", "tuskyAccountId"), + ] +) +@TypeConverters(Converters::class) +data class NotificationReportEntity( + // id of the account logged into Tusky this report belongs to + val tuskyAccountId: Long, + val serverId: String, + val category: String, + val statusIds: List?, + val createdAt: Date, + val targetAccountId: String? +) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/entity/NotificationPolicyEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/NotificationPolicyEntity.kt new file mode 100644 index 000000000..bf01dfbee --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/NotificationPolicyEntity.kt @@ -0,0 +1,11 @@ +package com.keylesspalace.tusky.db.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class NotificationPolicyEntity( + @PrimaryKey val tuskyAccountId: Long, + val pendingRequestsCount: Int, + val pendingNotificationsCount: Int +) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineAccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineAccountEntity.kt new file mode 100644 index 000000000..2e8656355 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineAccountEntity.kt @@ -0,0 +1,39 @@ +/* Copyright 2024 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.db.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.TypeConverters +import com.keylesspalace.tusky.db.Converters +import com.keylesspalace.tusky.entity.Emoji + +@Entity( + primaryKeys = ["serverId", "tuskyAccountId"] +) +@TypeConverters(Converters::class) +data class TimelineAccountEntity( + val serverId: String, + val tuskyAccountId: Long, + val localUsername: String, + val username: String, + val displayName: String, + val url: String, + val avatar: String, + @ColumnInfo(defaultValue = "") val note: String, + val emojis: List, + val bot: Boolean +) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineStatusEntity.kt similarity index 50% rename from app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt rename to app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineStatusEntity.kt index ba32f6380..ec3688864 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineStatusEntity.kt @@ -13,40 +13,38 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.db +package com.keylesspalace.tusky.db.entity -import androidx.room.Embedded import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.TypeConverters +import com.keylesspalace.tusky.db.Converters +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.FilterResult +import com.keylesspalace.tusky.entity.HashTag +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.PreviewCard import com.keylesspalace.tusky.entity.Status /** - * We're trying to play smart here. Server sends us reblogs as two entities one embedded into - * another (reblogged status is a field inside of "reblog" status). But it's really inefficient from - * the DB perspective and doesn't matter much for the display/interaction purposes. - * What if when we store reblog we don't store almost empty "reblog status" but we store - * *reblogged* status and we embed "reblog status" into reblogged status. This reversed - * relationship takes much less space and is much faster to fetch (no N+1 type queries or JSON - * serialization). - * "Reblog status", if present, is marked by [reblogServerId], and [reblogAccountId] - * fields. + * Entity for caching status data. Used within home timelines and notifications. + * The information if a status is a reblog is not stored here but in [HomeTimelineEntity]. */ @Entity( - primaryKeys = ["serverId", "timelineUserId"], + primaryKeys = ["serverId", "tuskyAccountId"], foreignKeys = ( [ ForeignKey( entity = TimelineAccountEntity::class, - parentColumns = ["serverId", "timelineUserId"], - childColumns = ["authorServerId", "timelineUserId"] + parentColumns = ["serverId", "tuskyAccountId"], + childColumns = ["authorServerId", "tuskyAccountId"] ) ] ), // Avoiding rescanning status table when accounts table changes. Recommended by Room(c). - indices = [Index("authorServerId", "timelineUserId")] + indices = [Index("authorServerId", "tuskyAccountId")] ) @TypeConverters(Converters::class) data class TimelineStatusEntity( @@ -54,14 +52,14 @@ data class TimelineStatusEntity( val serverId: String, val url: String?, // our local id for the logged in user in case there are multiple accounts per instance - val timelineUserId: Long, - val authorServerId: String?, + val tuskyAccountId: Long, + val authorServerId: String, val inReplyToId: String?, val inReplyToAccountId: String?, - val content: String?, + val content: String, val createdAt: Long, val editedAt: Long?, - val emojis: String?, + val emojis: List, val reblogsCount: Int, val favouritesCount: Int, val repliesCount: Int, @@ -71,50 +69,19 @@ data class TimelineStatusEntity( val sensitive: Boolean, val spoilerText: String, val visibility: Status.Visibility, - val attachments: String?, - val mentions: String?, - val tags: String?, - val application: String?, + val attachments: List, + val mentions: List, + val tags: List, + val application: Status.Application?, // if it has a reblogged status, it's id is stored here - val reblogServerId: String?, - val reblogAccountId: String?, - val poll: String?, - val muted: Boolean?, + val poll: Poll?, + val muted: Boolean, /** Also used as the "loading" attribute when this TimelineStatusEntity is a placeholder */ val expanded: Boolean, val contentCollapsed: Boolean, val contentShowing: Boolean, val pinned: Boolean, - val card: String?, + val card: PreviewCard?, val language: String?, - val filtered: List? -) { - val isPlaceholder: Boolean - get() = this.authorServerId == null -} - -@Entity( - primaryKeys = ["serverId", "timelineUserId"] -) -data class TimelineAccountEntity( - val serverId: String, - val timelineUserId: Long, - val localUsername: String, - val username: String, - val displayName: String, - val url: String, - val avatar: String, - val emojis: String, - val bot: Boolean -) - -data class TimelineStatusWithAccount( - @Embedded - val status: TimelineStatusEntity, - // null when placeholder - @Embedded(prefix = "a_") - val account: TimelineAccountEntity? = null, - // null when no reblog - @Embedded(prefix = "rb_") - val reblogAccount: TimelineAccountEntity? = null + val filtered: List ) diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt deleted file mode 100644 index a8fe4b9b6..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ /dev/null @@ -1,135 +0,0 @@ -/* Copyright 2018 charlag - * - * 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.di - -import com.keylesspalace.tusky.AboutActivity -import com.keylesspalace.tusky.BaseActivity -import com.keylesspalace.tusky.EditProfileActivity -import com.keylesspalace.tusky.LicenseActivity -import com.keylesspalace.tusky.ListsActivity -import com.keylesspalace.tusky.MainActivity -import com.keylesspalace.tusky.SplashActivity -import com.keylesspalace.tusky.StatusListActivity -import com.keylesspalace.tusky.TabPreferenceActivity -import com.keylesspalace.tusky.ViewMediaActivity -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.domainblocks.DomainBlocksActivity -import com.keylesspalace.tusky.components.drafts.DraftsActivity -import com.keylesspalace.tusky.components.filters.EditFilterActivity -import com.keylesspalace.tusky.components.filters.FiltersActivity -import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity -import com.keylesspalace.tusky.components.login.LoginActivity -import com.keylesspalace.tusky.components.login.LoginWebViewActivity -import com.keylesspalace.tusky.components.preference.PreferencesActivity -import com.keylesspalace.tusky.components.report.ReportActivity -import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity -import com.keylesspalace.tusky.components.search.SearchActivity -import com.keylesspalace.tusky.components.trending.TrendingActivity -import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity -import dagger.Module -import dagger.android.ContributesAndroidInjector - -/** - * Created by charlag on 3/24/18. - */ - -@Module -abstract class ActivitiesModule { - - @ContributesAndroidInjector - abstract fun contributesBaseActivity(): BaseActivity - - @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) - abstract fun contributesMainActivity(): MainActivity - - @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) - abstract fun contributesAccountActivity(): AccountActivity - - @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) - abstract fun contributesListsActivity(): ListsActivity - - @ContributesAndroidInjector - abstract fun contributesComposeActivity(): ComposeActivity - - @ContributesAndroidInjector - abstract fun contributesEditProfileActivity(): EditProfileActivity - - @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) - abstract fun contributesAccountListActivity(): AccountListActivity - - @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) - abstract fun contributesViewThreadActivity(): ViewThreadActivity - - @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) - abstract fun contributesStatusListActivity(): StatusListActivity - - @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) - abstract fun contributesSearchActivity(): SearchActivity - - @ContributesAndroidInjector - abstract fun contributesAboutActivity(): AboutActivity - - @ContributesAndroidInjector - abstract fun contributesLoginActivity(): LoginActivity - - @ContributesAndroidInjector - abstract fun contributesLoginWebViewActivity(): LoginWebViewActivity - - @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) - abstract fun contributesPreferencesActivity(): PreferencesActivity - - @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) - abstract fun contributesViewMediaActivity(): ViewMediaActivity - - @ContributesAndroidInjector - abstract fun contributesLicenseActivity(): LicenseActivity - - @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) - abstract fun contributesTabPreferenceActivity(): TabPreferenceActivity - - @ContributesAndroidInjector - abstract fun contributesFiltersActivity(): FiltersActivity - - @ContributesAndroidInjector - abstract fun contributesFollowedTagsActivity(): FollowedTagsActivity - - @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) - abstract fun contributesReportActivity(): ReportActivity - - @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) - abstract fun contributesInstanceListActivity(): DomainBlocksActivity - - @ContributesAndroidInjector - abstract fun contributesScheduledStatusActivity(): ScheduledStatusActivity - - @ContributesAndroidInjector - abstract fun contributesAnnouncementsActivity(): AnnouncementsActivity - - @ContributesAndroidInjector - abstract fun contributesDraftActivity(): DraftsActivity - - @ContributesAndroidInjector - abstract fun contributesSplashActivity(): SplashActivity - - @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) - abstract fun contributesTrendingActivity(): TrendingActivity - - @ContributesAndroidInjector - abstract fun contributesEditFilterActivity(): EditFilterActivity -} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt deleted file mode 100644 index 91045baef..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* Copyright 2018 charlag - * - * 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.di - -import com.keylesspalace.tusky.TuskyApplication -import dagger.BindsInstance -import dagger.Component -import dagger.android.support.AndroidSupportInjectionModule -import javax.inject.Singleton - -/** - * Created by charlag on 3/21/18. - */ - -@Singleton -@Component( - modules = [ - AppModule::class, - CoroutineScopeModule::class, - NetworkModule::class, - AndroidSupportInjectionModule::class, - ActivitiesModule::class, - ServicesModule::class, - BroadcastReceiverModule::class, - ViewModelModule::class, - WorkerModule::class, - PlayerModule::class - ] -) -interface AppComponent { - @Component.Builder - interface Builder { - @BindsInstance - fun application(tuskyApp: TuskyApplication): Builder - - fun build(): AppComponent - } - - fun inject(app: TuskyApplication) -} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppInjector.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppInjector.kt deleted file mode 100644 index fa6fae851..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppInjector.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* Copyright 2018 charlag - * - * 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.di - -import android.app.Activity -import android.app.Application -import android.content.Context -import android.os.Bundle -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.fragment.app.FragmentManager -import com.keylesspalace.tusky.TuskyApplication -import dagger.android.AndroidInjection -import dagger.android.HasAndroidInjector -import dagger.android.support.AndroidSupportInjection - -/** - * Created by charlag on 3/24/18. - */ - -object AppInjector { - fun init(app: TuskyApplication) { - DaggerAppComponent.builder().application(app) - .build().inject(app) - - app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks { - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { - handleActivity(activity) - } - - override fun onActivityPaused(activity: Activity) { - } - - override fun onActivityResumed(activity: Activity) { - } - - override fun onActivityStarted(activity: Activity) { - } - - override fun onActivityDestroyed(activity: Activity) { - } - - override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { - } - - override fun onActivityStopped(activity: Activity) { - } - }) - } - - private fun handleActivity(activity: Activity) { - if (activity is HasAndroidInjector || activity is Injectable) { - AndroidInjection.inject(activity) - } - if (activity is FragmentActivity) { - activity.supportFragmentManager.registerFragmentLifecycleCallbacks( - object : FragmentManager.FragmentLifecycleCallbacks() { - override fun onFragmentPreAttached( - fm: FragmentManager, - f: Fragment, - context: Context - ) { - if (f is Injectable) { - AndroidSupportInjection.inject(f) - } - } - }, - true - ) - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt deleted file mode 100644 index 82c83e1d9..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* Copyright 2018 Jeremiasz Nelz - * Copyright 2018 Conny Duck - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.di - -import com.keylesspalace.tusky.receiver.NotificationBlockStateBroadcastReceiver -import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver -import com.keylesspalace.tusky.receiver.UnifiedPushBroadcastReceiver -import dagger.Module -import dagger.android.ContributesAndroidInjector - -@Module -abstract class BroadcastReceiverModule { - @ContributesAndroidInjector - abstract fun contributeSendStatusBroadcastReceiver(): SendStatusBroadcastReceiver - - @ContributesAndroidInjector - abstract fun contributeUnifiedPushBroadcastReceiver(): UnifiedPushBroadcastReceiver - - @ContributesAndroidInjector - abstract fun contributeNotificationBlockStateBroadcastReceiver(): NotificationBlockStateBroadcastReceiver -} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt index 7eef6543a..61f513f51 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt @@ -19,6 +19,8 @@ package com.keylesspalace.tusky.di import dagger.Module import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent import javax.inject.Qualifier import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope @@ -38,7 +40,8 @@ import kotlinx.coroutines.SupervisorJob annotation class ApplicationScope @Module -class CoroutineScopeModule { +@InstallIn(SingletonComponent::class) +object CoroutineScopeModule { @ApplicationScope @Provides @Singleton diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt deleted file mode 100644 index b1a8b17ad..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ /dev/null @@ -1,110 +0,0 @@ -/* Copyright 2018 charlag - * - * 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.di - -import com.keylesspalace.tusky.AccountsInListFragment -import com.keylesspalace.tusky.components.account.list.ListSelectionFragment -import com.keylesspalace.tusky.components.account.media.AccountMediaFragment -import com.keylesspalace.tusky.components.accountlist.AccountListFragment -import com.keylesspalace.tusky.components.conversation.ConversationsFragment -import com.keylesspalace.tusky.components.domainblocks.DomainBlocksFragment -import com.keylesspalace.tusky.components.preference.AccountPreferencesFragment -import com.keylesspalace.tusky.components.preference.NotificationPreferencesFragment -import com.keylesspalace.tusky.components.preference.PreferencesFragment -import com.keylesspalace.tusky.components.preference.TabFilterPreferencesFragment -import com.keylesspalace.tusky.components.report.fragments.ReportDoneFragment -import com.keylesspalace.tusky.components.report.fragments.ReportNoteFragment -import com.keylesspalace.tusky.components.report.fragments.ReportStatusesFragment -import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragment -import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment -import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment -import com.keylesspalace.tusky.components.timeline.TimelineFragment -import com.keylesspalace.tusky.components.trending.TrendingTagsFragment -import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment -import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment -import com.keylesspalace.tusky.fragment.NotificationsFragment -import com.keylesspalace.tusky.fragment.ViewVideoFragment -import dagger.Module -import dagger.android.ContributesAndroidInjector - -@Module -abstract class FragmentBuildersModule { - @ContributesAndroidInjector - abstract fun accountListFragment(): AccountListFragment - - @ContributesAndroidInjector - abstract fun accountMediaFragment(): AccountMediaFragment - - @ContributesAndroidInjector - abstract fun viewThreadFragment(): ViewThreadFragment - - @ContributesAndroidInjector - abstract fun viewEditsFragment(): ViewEditsFragment - - @ContributesAndroidInjector - abstract fun timelineFragment(): TimelineFragment - - @ContributesAndroidInjector - abstract fun notificationsFragment(): NotificationsFragment - - @ContributesAndroidInjector - abstract fun notificationPreferencesFragment(): NotificationPreferencesFragment - - @ContributesAndroidInjector - abstract fun accountPreferencesFragment(): AccountPreferencesFragment - - @ContributesAndroidInjector - abstract fun conversationsFragment(): ConversationsFragment - - @ContributesAndroidInjector - abstract fun accountInListsFragment(): AccountsInListFragment - - @ContributesAndroidInjector - abstract fun reportStatusesFragment(): ReportStatusesFragment - - @ContributesAndroidInjector - abstract fun reportNoteFragment(): ReportNoteFragment - - @ContributesAndroidInjector - abstract fun reportDoneFragment(): ReportDoneFragment - - @ContributesAndroidInjector - abstract fun instanceListFragment(): DomainBlocksFragment - - @ContributesAndroidInjector - abstract fun searchStatusesFragment(): SearchStatusesFragment - - @ContributesAndroidInjector - abstract fun searchAccountFragment(): SearchAccountsFragment - - @ContributesAndroidInjector - abstract fun searchHashtagsFragment(): SearchHashtagsFragment - - @ContributesAndroidInjector - abstract fun preferencesFragment(): PreferencesFragment - - @ContributesAndroidInjector - abstract fun listsForAccountFragment(): ListSelectionFragment - - @ContributesAndroidInjector - abstract fun trendingTagsFragment(): TrendingTagsFragment - - @ContributesAndroidInjector - abstract fun viewVideoFragment(): ViewVideoFragment - - @ContributesAndroidInjector - abstract fun tabFilterPreferencesFragment(): TabFilterPreferencesFragment -} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt index b06ed2afd..d7d16fcf2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -26,9 +26,10 @@ import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.json.GuardedAdapter -import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor +import com.keylesspalace.tusky.json.NotificationTypeAdapter import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MediaUploadApi +import com.keylesspalace.tusky.network.apiForAccount import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_ENABLED import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_PORT import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_SERVER @@ -39,11 +40,15 @@ import com.squareup.moshi.adapters.EnumJsonAdapter import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter import dagger.Module import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent import java.net.IDN import java.net.InetSocketAddress import java.net.Proxy import java.util.Date import java.util.concurrent.TimeUnit +import javax.inject.Named import javax.inject.Singleton import okhttp3.Cache import okhttp3.OkHttp @@ -51,22 +56,34 @@ import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory -import retrofit2.create /** * Created by charlag on 3/24/18. */ @Module +@InstallIn(SingletonComponent::class) object NetworkModule { private const val TAG = "NetworkModule" + @Provides + @Named("defaultPort") + fun providesDefaultPort(): Int { + return 443 + } + + @Provides + @Named("defaultScheme") + fun providesDefaultScheme(): String { + return "https://" + } + @Provides @Singleton fun providesMoshi(): Moshi = Moshi.Builder() - .add(Date::class.java, Rfc3339DateJsonAdapter()) .add(GuardedAdapter.ANNOTATION_FACTORY) + .add(Date::class.java, Rfc3339DateJsonAdapter()) // Enum types with fallback value .add( Attachment.Type::class.java, @@ -75,8 +92,7 @@ object NetworkModule { ) .add( Notification.Type::class.java, - EnumJsonAdapter.create(Notification.Type::class.java) - .withUnknownFallback(Notification.Type.UNKNOWN) + NotificationTypeAdapter() ) .add( Status.Visibility::class.java, @@ -88,8 +104,7 @@ object NetworkModule { @Provides @Singleton fun providesHttpClient( - accountManager: AccountManager, - context: Context, + @ApplicationContext context: Context, preferences: SharedPreferences ): OkHttpClient { val httpProxyEnabled = preferences.getBoolean(HTTP_PROXY_ENABLED, false) @@ -121,23 +136,22 @@ object NetworkModule { builder.proxy(Proxy(Proxy.Type.HTTP, address)) } ?: Log.w(TAG, "Invalid proxy configuration: ($httpServer, $httpPort)") } - - return builder - .apply { - addInterceptor(InstanceSwitchAuthInterceptor(accountManager)) - if (BuildConfig.DEBUG) { - addInterceptor( - HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC } - ) - } - } - .build() + if (BuildConfig.DEBUG) { + builder.addInterceptor( + HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC } + ) + } + return builder.build() } @Provides @Singleton - fun providesRetrofit(httpClient: OkHttpClient, moshi: Moshi): Retrofit { - return Retrofit.Builder().baseUrl("https://" + MastodonApi.PLACEHOLDER_DOMAIN) + fun providesRetrofit( + httpClient: OkHttpClient, + moshi: Moshi + ): Retrofit { + return Retrofit.Builder() + .baseUrl("https://${MastodonApi.PLACEHOLDER_DOMAIN}") .client(httpClient) .addConverterFactory(MoshiConverterFactory.create(moshi)) .addCallAdapterFactory(NetworkResultCallAdapterFactory.create()) @@ -145,20 +159,25 @@ object NetworkModule { } @Provides - @Singleton - fun providesApi(retrofit: Retrofit): MastodonApi = retrofit.create() + fun providesMastodonApi( + httpClient: OkHttpClient, + retrofit: Retrofit, + accountManager: AccountManager + ): MastodonApi { + return apiForAccount(accountManager.activeAccount, httpClient, retrofit) + } @Provides - @Singleton - fun providesMediaUploadApi(retrofit: Retrofit, okHttpClient: OkHttpClient): MediaUploadApi { + fun providesMediaUploadApi( + retrofit: Retrofit, + okHttpClient: OkHttpClient, + accountManager: AccountManager + ): MediaUploadApi { val longTimeOutOkHttpClient = okHttpClient.newBuilder() .readTimeout(100, TimeUnit.SECONDS) .writeTimeout(100, TimeUnit.SECONDS) .build() - return retrofit.newBuilder() - .client(longTimeOutOkHttpClient) - .build() - .create() + return apiForAccount(accountManager.activeAccount, longTimeOutOkHttpClient, retrofit) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NotificationManagerModule.kt similarity index 56% rename from app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt rename to app/src/main/java/com/keylesspalace/tusky/di/NotificationManagerModule.kt index 1d7510a2c..65c53abd7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NotificationManagerModule.kt @@ -1,4 +1,4 @@ -/* Copyright 2018 Conny Duck +/* Copyright 2025 Tusky contributors * * This file is a part of Tusky. * @@ -15,12 +15,19 @@ package com.keylesspalace.tusky.di -import com.keylesspalace.tusky.service.SendStatusService +import android.app.NotificationManager +import android.content.Context import dagger.Module -import dagger.android.ContributesAndroidInjector +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent @Module -abstract class ServicesModule { - @ContributesAndroidInjector - abstract fun contributesSendStatusService(): SendStatusService +@InstallIn(SingletonComponent::class) +object NotificationManagerModule { + @Provides + fun providesNotificationManager(@ApplicationContext appContext: Context): NotificationManager { + return appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/PlayerModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/PlayerModule.kt index f12587fc5..ac5cf7bab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/PlayerModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/PlayerModule.kt @@ -53,19 +53,26 @@ import androidx.media3.extractor.text.webvtt.WebvttParser import androidx.media3.extractor.wav.WavExtractor import dagger.Module import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent import okhttp3.OkHttpClient @Module +@InstallIn(SingletonComponent::class) @OptIn(UnstableApi::class) object PlayerModule { @Provides - fun provideAudioSink(context: Context): AudioSink { + fun provideAudioSink(@ApplicationContext context: Context): AudioSink { return DefaultAudioSink.Builder(context) .build() } @Provides - fun provideRenderersFactory(context: Context, audioSink: AudioSink): RenderersFactory { + fun provideRenderersFactory( + @ApplicationContext context: Context, + audioSink: AudioSink + ): RenderersFactory { return RenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, @@ -154,7 +161,10 @@ object PlayerModule { } @Provides - fun provideDataSourceFactory(context: Context, okHttpClient: OkHttpClient): DataSource.Factory { + fun provideDataSourceFactory( + @ApplicationContext context: Context, + okHttpClient: OkHttpClient + ): DataSource.Factory { return DefaultDataSource.Factory(context, OkHttpDataSource.Factory(okHttpClient)) } @@ -169,7 +179,7 @@ object PlayerModule { @Provides fun provideExoPlayer( - context: Context, + @ApplicationContext context: Context, renderersFactory: RenderersFactory, mediaSourceFactory: MediaSource.Factory ): ExoPlayer { diff --git a/app/src/main/java/com/keylesspalace/tusky/di/PreferencesEntryPoint.kt b/app/src/main/java/com/keylesspalace/tusky/di/PreferencesEntryPoint.kt new file mode 100644 index 000000000..5ff1c31c4 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/PreferencesEntryPoint.kt @@ -0,0 +1,12 @@ +package com.keylesspalace.tusky.di + +import android.content.SharedPreferences +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface PreferencesEntryPoint { + fun preferences(): SharedPreferences +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/StorageModule.kt similarity index 85% rename from app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt rename to app/src/main/java/com/keylesspalace/tusky/di/StorageModule.kt index 0f9277dda..b17fdbb4e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/StorageModule.kt @@ -15,16 +15,17 @@ package com.keylesspalace.tusky.di -import android.app.Application import android.content.Context import android.content.SharedPreferences import androidx.preference.PreferenceManager import androidx.room.Room -import com.keylesspalace.tusky.TuskyApplication import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.Converters import dagger.Module import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent import javax.inject.Singleton /** @@ -32,25 +33,19 @@ import javax.inject.Singleton */ @Module -class AppModule { +@InstallIn(SingletonComponent::class) +object StorageModule { @Provides - fun providesApplication(app: TuskyApplication): Application = app - - @Provides - fun providesContext(app: Application): Context = app - - @Provides - fun providesSharedPreferences(app: Application): SharedPreferences { - return PreferenceManager.getDefaultSharedPreferences(app) + fun providesSharedPreferences(@ApplicationContext appContext: Context): SharedPreferences { + return PreferenceManager.getDefaultSharedPreferences(appContext) } @Provides @Singleton - fun providesDatabase(appContext: Context, converters: Converters): AppDatabase { + fun providesDatabase(@ApplicationContext appContext: Context, converters: Converters): AppDatabase { return Room.databaseBuilder(appContext, AppDatabase::class.java, "tuskyDB") .addTypeConverter(converters) - .allowMainThreadQueries() .addMigrations( AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7, AppDatabase.MIGRATION_7_8, @@ -68,7 +63,8 @@ class AppModule { AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41, AppDatabase.MIGRATION_41_42, AppDatabase.MIGRATION_42_43, AppDatabase.MIGRATION_43_44, AppDatabase.MIGRATION_44_45, AppDatabase.MIGRATION_45_46, AppDatabase.MIGRATION_46_47, - AppDatabase.MIGRATION_47_48, AppDatabase.MIGRATION_52_53, AppDatabase.MIGRATION_54_56 + AppDatabase.MIGRATION_47_48, AppDatabase.MIGRATION_52_53, AppDatabase.MIGRATION_54_56, + AppDatabase.MIGRATION_58_60, AppDatabase.MIGRATION_60_62 ) .build() } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt deleted file mode 100644 index d2c86a598..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -// from https://proandroiddev.com/viewmodel-with-dagger2-architecture-components-2e06f06c9455 - -package com.keylesspalace.tusky.di - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.keylesspalace.tusky.components.account.AccountViewModel -import com.keylesspalace.tusky.components.account.list.ListsForAccountViewModel -import com.keylesspalace.tusky.components.account.media.AccountMediaViewModel -import com.keylesspalace.tusky.components.announcements.AnnouncementsViewModel -import com.keylesspalace.tusky.components.compose.ComposeViewModel -import com.keylesspalace.tusky.components.conversation.ConversationsViewModel -import com.keylesspalace.tusky.components.domainblocks.DomainBlocksViewModel -import com.keylesspalace.tusky.components.drafts.DraftsViewModel -import com.keylesspalace.tusky.components.filters.EditFilterViewModel -import com.keylesspalace.tusky.components.filters.FiltersViewModel -import com.keylesspalace.tusky.components.followedtags.FollowedTagsViewModel -import com.keylesspalace.tusky.components.login.LoginWebViewViewModel -import com.keylesspalace.tusky.components.report.ReportViewModel -import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel -import com.keylesspalace.tusky.components.search.SearchViewModel -import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel -import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel -import com.keylesspalace.tusky.components.trending.viewmodel.TrendingTagsViewModel -import com.keylesspalace.tusky.components.viewthread.ViewThreadViewModel -import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsViewModel -import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel -import com.keylesspalace.tusky.viewmodel.EditProfileViewModel -import com.keylesspalace.tusky.viewmodel.ListsViewModel -import dagger.Binds -import dagger.MapKey -import dagger.Module -import dagger.multibindings.IntoMap -import javax.inject.Inject -import javax.inject.Provider -import javax.inject.Singleton -import kotlin.reflect.KClass - -@Singleton -class ViewModelFactory @Inject constructor( - private val viewModels: MutableMap, Provider> -) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = - viewModels[modelClass]?.get() as T -} - -@Target( - AnnotationTarget.FUNCTION, - AnnotationTarget.PROPERTY_GETTER, - AnnotationTarget.PROPERTY_SETTER -) -@Retention(AnnotationRetention.RUNTIME) -@MapKey -internal annotation class ViewModelKey(val value: KClass) - -@Module -abstract class ViewModelModule { - - @Binds - internal abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory - - @Binds - @IntoMap - @ViewModelKey(AccountViewModel::class) - internal abstract fun accountViewModel(viewModel: AccountViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(EditProfileViewModel::class) - internal abstract fun editProfileViewModel(viewModel: EditProfileViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(ConversationsViewModel::class) - internal abstract fun conversationsViewModel(viewModel: ConversationsViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(ListsViewModel::class) - internal abstract fun listsViewModel(viewModel: ListsViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(AccountsInListViewModel::class) - internal abstract fun accountsInListViewModel(viewModel: AccountsInListViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(ReportViewModel::class) - internal abstract fun reportViewModel(viewModel: ReportViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(SearchViewModel::class) - internal abstract fun searchViewModel(viewModel: SearchViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(ComposeViewModel::class) - internal abstract fun composeViewModel(viewModel: ComposeViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(ScheduledStatusViewModel::class) - internal abstract fun scheduledStatusViewModel(viewModel: ScheduledStatusViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(AnnouncementsViewModel::class) - internal abstract fun announcementsViewModel(viewModel: AnnouncementsViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(DraftsViewModel::class) - internal abstract fun draftsViewModel(viewModel: DraftsViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(CachedTimelineViewModel::class) - internal abstract fun cachedTimelineViewModel(viewModel: CachedTimelineViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(NetworkTimelineViewModel::class) - internal abstract fun networkTimelineViewModel(viewModel: NetworkTimelineViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(ViewThreadViewModel::class) - internal abstract fun viewThreadViewModel(viewModel: ViewThreadViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(ViewEditsViewModel::class) - internal abstract fun viewEditsViewModel(viewModel: ViewEditsViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(AccountMediaViewModel::class) - internal abstract fun accountMediaViewModel(viewModel: AccountMediaViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(LoginWebViewViewModel::class) - internal abstract fun loginWebViewViewModel(viewModel: LoginWebViewViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(FollowedTagsViewModel::class) - internal abstract fun followedTagsViewModel(viewModel: FollowedTagsViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(ListsForAccountViewModel::class) - internal abstract fun listsForAccountViewModel(viewModel: ListsForAccountViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(TrendingTagsViewModel::class) - internal abstract fun trendingTagsViewModel(viewModel: TrendingTagsViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(FiltersViewModel::class) - internal abstract fun filtersViewModel(viewModel: FiltersViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(EditFilterViewModel::class) - internal abstract fun editFilterViewModel(viewModel: EditFilterViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(DomainBlocksViewModel::class) - internal abstract fun instanceMuteViewModel(viewModel: DomainBlocksViewModel): ViewModel - - // Add more ViewModels here -} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/WorkerModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/WorkerModule.kt deleted file mode 100644 index a2cccf992..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/di/WorkerModule.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.di - -import androidx.work.ListenableWorker -import com.keylesspalace.tusky.worker.ChildWorkerFactory -import com.keylesspalace.tusky.worker.NotificationWorker -import com.keylesspalace.tusky.worker.PruneCacheWorker -import dagger.Binds -import dagger.MapKey -import dagger.Module -import dagger.multibindings.IntoMap -import kotlin.reflect.KClass - -@Retention(AnnotationRetention.RUNTIME) -@MapKey -annotation class WorkerKey(val value: KClass) - -@Module -abstract class WorkerModule { - @Binds - @IntoMap - @WorkerKey(NotificationWorker::class) - internal abstract fun bindNotificationWorkerFactory( - worker: NotificationWorker.Factory - ): ChildWorkerFactory - - @Binds - @IntoMap - @WorkerKey(PruneCacheWorker::class) - internal abstract fun bindPruneCacheWorkerFactory( - worker: PruneCacheWorker.Factory - ): ChildWorkerFactory -} diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/AccountWarning.kt b/app/src/main/java/com/keylesspalace/tusky/entity/AccountWarning.kt new file mode 100644 index 000000000..9d01891f5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/AccountWarning.kt @@ -0,0 +1,52 @@ +/* Copyright 2025 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.entity + +import androidx.annotation.StringRes +import com.keylesspalace.tusky.R +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class AccountWarning( + val id: String, + val action: Action +) { + + @JsonClass(generateAdapter = false) + enum class Action(@StringRes val text: Int) { + @Json(name = "none") + NONE(R.string.moderation_warning_action_none), + + @Json(name = "disable") + DISABLE(R.string.moderation_warning_action_disable), + + @Json(name = "mark_statuses_as_sensitive") + MARK_STATUSES_AS_SENSITIVE(R.string.moderation_warning_action_mark_statuses_as_sensitive), + + @Json(name = "delete_statuses") + DELETE_STATUSES(R.string.moderation_warning_action_delete_statuses), + + @Json(name = "sensitive") + SENSITIVE(R.string.moderation_warning_action_sensitive), + + @Json(name = "silence") + SILENCE(R.string.moderation_warning_action_silence), + + @Json(name = "suspend") + SUSPEND(R.string.moderation_warning_action_suspend), + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt index 792c2423b..0ad45e2ef 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt @@ -30,7 +30,6 @@ data class Announcement( @Json(name = "updated_at") val updatedAt: Date, val read: Boolean = false, val mentions: List, - val statuses: List, val tags: List, val emojis: List, val reactions: List diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt index 564ba3dad..57c1c6e42 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt @@ -27,12 +27,18 @@ data class Attachment( val url: String, // can be null for e.g. audio attachments @Json(name = "preview_url") val previewUrl: String? = null, + // null when local attachment + @Json(name = "remote_url") val remoteUrl: String? = null, val meta: MetaData? = null, val type: Type, val description: String? = null, val blurhash: String? = null ) : Parcelable { + /** The url to open for attachments of unknown type */ + val unknownUrl: String + get() = remoteUrl ?: url + @JsonClass(generateAdapter = false) enum class Type { @Json(name = "image") @@ -71,8 +77,8 @@ data class Attachment( @JsonClass(generateAdapter = true) @Parcelize data class Focus( - val x: Float, - val y: Float + val x: Float?, + val y: Float? ) : Parcelable { fun toMastodonApiString(): String = "$x,$y" } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt index c4325a60d..14061c35f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt @@ -26,5 +26,6 @@ data class Emoji( val shortcode: String, val url: String, @Json(name = "static_url") val staticUrl: String, - @Json(name = "visible_in_picker") val visibleInPicker: Boolean = true + @Json(name = "visible_in_picker") val visibleInPicker: Boolean = true, + val category: String? ) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt b/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt index 7a8c9b17e..40f48e1cd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt @@ -28,13 +28,6 @@ data class FilterV1( val irreversible: Boolean, @Json(name = "whole_word") val wholeWord: Boolean ) { - companion object { - const val HOME = "home" - const val NOTIFICATIONS = "notifications" - const val PUBLIC = "public" - const val THREAD = "thread" - const val ACCOUNT = "account" - } override fun hashCode(): Int { return id.hashCode() diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt index 92e71ba65..1fceb599e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt @@ -51,7 +51,11 @@ data class Instance( data class Urls(@Json(name = "streaming_api") val streamingApi: String? = null) @JsonClass(generateAdapter = true) - data class Accounts(@Json(name = "max_featured_tags") val maxFeaturedTags: Int) + data class Accounts( + @Json(name = "max_featured_tags") val maxFeaturedTags: Int, + // GoToSocial feature + @Json(name = "max_profile_fields") val maxProfileFields: Int? + ) @JsonClass(generateAdapter = true) data class Statuses( diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt index 25b8b1f92..b06b2f92f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -15,8 +15,17 @@ package com.keylesspalace.tusky.entity -import androidx.annotation.StringRes -import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Notification.Type +import com.keylesspalace.tusky.entity.Notification.Type.Favourite +import com.keylesspalace.tusky.entity.Notification.Type.Follow +import com.keylesspalace.tusky.entity.Notification.Type.FollowRequest +import com.keylesspalace.tusky.entity.Notification.Type.Mention +import com.keylesspalace.tusky.entity.Notification.Type.ModerationWarning +import com.keylesspalace.tusky.entity.Notification.Type.Reblog +import com.keylesspalace.tusky.entity.Notification.Type.SeveredRelationship +import com.keylesspalace.tusky.entity.Notification.Type.SignUp +import com.keylesspalace.tusky.entity.Notification.Type.Unknown +import com.keylesspalace.tusky.entity.Notification.Type.Update import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @@ -26,96 +35,79 @@ data class Notification( val id: String, val account: TimelineAccount, val status: Status? = null, - val report: Report? = null + val report: Report? = null, + val filtered: Boolean = false, + val event: RelationshipSeveranceEvent? = null, + @Json(name = "moderation_warning") val moderationWarning: AccountWarning? = null ) { /** From https://docs.joinmastodon.org/entities/Notification/#type */ @JsonClass(generateAdapter = false) - enum class Type(val presentation: String, @StringRes val uiString: Int) { - UNKNOWN("unknown", R.string.notification_unknown_name), + sealed class Type(val name: String) { + data class Unknown(val unknownName: String) : Type(unknownName) /** Someone mentioned you */ - @Json(name = "mention") - MENTION("mention", R.string.notification_mention_name), + object Mention : Type("mention") /** Someone boosted one of your statuses */ - @Json(name = "reblog") - REBLOG("reblog", R.string.notification_boost_name), + object Reblog : Type("reblog") /** Someone favourited one of your statuses */ - @Json(name = "favourite") - FAVOURITE("favourite", R.string.notification_favourite_name), + object Favourite : Type("favourite") /** Someone followed you */ - @Json(name = "follow") - FOLLOW("follow", R.string.notification_follow_name), + object Follow : Type("follow") /** Someone requested to follow you */ - @Json(name = "follow_request") - FOLLOW_REQUEST("follow_request", R.string.notification_follow_request_name), + object FollowRequest : Type("follow_request") /** A poll you have voted in or created has ended */ - @Json(name = "poll") - POLL("poll", R.string.notification_poll_name), + object Poll : Type("poll") /** Someone you enabled notifications for has posted a status */ - @Json(name = "status") - STATUS("status", R.string.notification_subscription_name), + object Status : Type("status") /** Someone signed up (optionally sent to admins) */ - @Json(name = "admin.sign_up") - SIGN_UP("admin.sign_up", R.string.notification_sign_up_name), + object SignUp : Type("admin.sign_up") /** A status you interacted with has been updated */ - @Json(name = "update") - UPDATE("update", R.string.notification_update_name), + object Update : Type("update") /** A new report has been filed */ - @Json(name = "admin.report") - REPORT("admin.report", R.string.notification_report_name); + object Report : Type("admin.report") - companion object { - @JvmStatic - fun byString(s: String): Type { - return entries.firstOrNull { it.presentation == s } ?: UNKNOWN - } + /** Some of your follow relationships have been severed as a result of a moderation or block event **/ + object SeveredRelationship : Type("severed_relationships") - /** Notification types for UI display (omits UNKNOWN) */ - val visibleTypes = - listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE, REPORT) - } + /** moderation_warning = A moderator has taken action against your account or has sent you a warning **/ + object ModerationWarning : Type("moderation_warning") - override fun toString(): String { - return presentation - } + // can't use data objects or this wouldn't work + override fun toString() = name } - override fun hashCode(): Int { - return id.hashCode() - } - - override fun equals(other: Any?): Boolean { - if (other !is Notification) { - return false - } - return other.id == this.id - } - - /** Helper for Java */ - fun copyWithStatus(status: Status?): Notification = copy(status = status) - // for Pleroma compatibility that uses Mention type fun rewriteToStatusTypeIfNeeded(accountId: String): Notification { - if (type == Type.MENTION && status != null) { + if (type == Mention && status != null) { return if (status.mentions.any { it.id == accountId } ) { this } else { - copy(type = Type.STATUS) + copy(type = Type.Status) } } return this } } + +/** Notification types for UI display (omits UNKNOWN) */ +/** this is not in a companion object so it gets initialized earlier, + * otherwise it might get initialized when a subclass is loaded, + * which leds to crash since those subclasses are referenced here */ +val visibleNotificationTypes = listOf(Mention, Reblog, Favourite, Follow, FollowRequest, Type.Poll, Type.Status, SignUp, Update, Type.Report, SeveredRelationship, ModerationWarning) + +fun notificationTypeFromString(s: String): Type { + return visibleNotificationTypes.firstOrNull { it.name == s.lowercase() } ?: Unknown(s) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NotificationPolicy.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationPolicy.kt new file mode 100644 index 000000000..42ab7b101 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationPolicy.kt @@ -0,0 +1,47 @@ +/* Copyright 2024 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.entity + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class NotificationPolicy( + @Json(name = "for_not_following") val forNotFollowing: State, + @Json(name = "for_not_followers") val forNotFollowers: State, + @Json(name = "for_new_accounts") val forNewAccounts: State, + @Json(name = "for_private_mentions") val forPrivateMentions: State, + @Json(name = "for_limited_accounts") val forLimitedAccounts: State, + val summary: Summary +) { + @JsonClass(generateAdapter = false) + enum class State { + @Json(name = "accept") + ACCEPT, + + @Json(name = "filter") + FILTER, + + @Json(name = "drop") + DROP + } + + @JsonClass(generateAdapter = true) + data class Summary( + @Json(name = "pending_requests_count") val pendingRequestsCount: Int, + @Json(name = "pending_notifications_count") val pendingNotificationsCount: Int + ) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/Injectable.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationRequest.kt similarity index 67% rename from app/src/main/java/com/keylesspalace/tusky/di/Injectable.kt rename to app/src/main/java/com/keylesspalace/tusky/entity/NotificationRequest.kt index f3b4e8105..eb8c7a67f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/Injectable.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationRequest.kt @@ -1,4 +1,4 @@ -/* Copyright 2018 charlag +/* Copyright 2024 Tusky contributors * * This file is a part of Tusky. * @@ -13,10 +13,14 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.di +package com.keylesspalace.tusky.entity -/** - * Created by charlag on 3/24/18. - */ +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass -interface Injectable +@JsonClass(generateAdapter = true) +data class NotificationRequest( + val id: String, + val account: Account, + @Json(name = "notifications_count") val notificationsCount: Int +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt index d0d84b93f..862debfb4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt @@ -22,5 +22,6 @@ import com.squareup.moshi.JsonClass data class NotificationSubscribeResult( val id: Int, val endpoint: String, + val alerts: Map, @Json(name = "server_key") val serverKey: String ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt b/app/src/main/java/com/keylesspalace/tusky/entity/PreviewCard.kt similarity index 69% rename from app/src/main/java/com/keylesspalace/tusky/entity/Card.kt rename to app/src/main/java/com/keylesspalace/tusky/entity/PreviewCard.kt index baba8cefa..5168f7421 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/PreviewCard.kt @@ -15,15 +15,21 @@ package com.keylesspalace.tusky.entity +import com.keylesspalace.tusky.json.Guarded import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import java.util.Date @JsonClass(generateAdapter = true) -data class Card( +data class PreviewCard( val url: String, val title: String, val description: String = "", - @Json(name = "author_name") val authorName: String = "", + val authors: List = emptyList(), + @Json(name = "author_name") val authorName: String? = null, + @Json(name = "provider_name") val providerName: String? = null, + // sometimes this date is invalid https://github.com/tuskyapp/Tusky/issues/4992 + @Json(name = "published_at") @Guarded val publishedAt: Date?, val image: String? = null, val type: String, val width: Int = 0, @@ -35,7 +41,7 @@ data class Card( override fun hashCode() = url.hashCode() override fun equals(other: Any?): Boolean { - if (other !is Card) { + if (other !is PreviewCard) { return false } return other.url == this.url @@ -45,3 +51,10 @@ data class Card( const val TYPE_PHOTO = "photo" } } + +@JsonClass(generateAdapter = true) +data class PreviewCardAuthor( + val name: String, + val url: String, + val account: TimelineAccount? +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/RelationshipSeveranceEvent.kt b/app/src/main/java/com/keylesspalace/tusky/entity/RelationshipSeveranceEvent.kt new file mode 100644 index 000000000..9007d4b44 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/RelationshipSeveranceEvent.kt @@ -0,0 +1,41 @@ +/* Copyright 2025 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.entity + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class RelationshipSeveranceEvent( + val id: String, + val type: Type, + @Json(name = "target_name") val targetName: String, + @Json(name = "followers_count") val followersCount: Int, + @Json(name = "following_count") val followingCount: Int +) { + + @JsonClass(generateAdapter = false) + enum class Type { + @Json(name = "domain_block") + DOMAIN_BLOCK, + + @Json(name = "user_domain_block") + USER_DOMAIN_BLOCK, + + @Json(name = "account_suspension") + ACCOUNT_SUSPENSION, + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt index 6be354d10..9df0324ab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt @@ -25,3 +25,10 @@ data class ScheduledStatus( val params: StatusParams, @Json(name = "media_attachments") val mediaAttachments: List ) + +// minimal class to avoid json parsing errors with servers that don't support scheduling +// https://github.com/tuskyapp/Tusky/issues/4703 +@JsonClass(generateAdapter = true) +data class ScheduledStatusReply( + val id: String, +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index db72bf44f..67b34e549 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -47,13 +47,13 @@ data class Status( @Json(name = "media_attachments") val attachments: List, val mentions: List, // Use null to mark the absence of tags because of semantic differences in LinkHelper - val tags: List? = null, + val tags: List = emptyList(), val application: Application? = null, val pinned: Boolean = false, val muted: Boolean = false, val poll: Poll? = null, /** Preview card for links included within status content. */ - val card: Card? = null, + val card: PreviewCard? = null, /** ISO 639 language code for this status. */ val language: String? = null, /** If the current token has an authorized user: The filter and keywords that matched this status. @@ -67,15 +67,11 @@ data class Status( val actionableStatus: Status get() = reblog ?: this - /** Helpers for Java */ - fun copyWithFavourited(favourited: Boolean): Status = copy(favourited = favourited) - fun copyWithReblogged(reblogged: Boolean): Status = copy(reblogged = reblogged) - fun copyWithBookmarked(bookmarked: Boolean): Status = copy(bookmarked = bookmarked) - fun copyWithPoll(poll: Poll?): Status = copy(poll = poll) - fun copyWithPinned(pinned: Boolean): Status = copy(pinned = pinned) + val isReply: Boolean + get() = inReplyToId != null @JsonClass(generateAdapter = false) - enum class Visibility(val num: Int) { + enum class Visibility(val int: Int) { UNKNOWN(0), @Json(name = "public") @@ -90,7 +86,7 @@ data class Status( @Json(name = "direct") DIRECT(4); - val serverString: String + val stringValue: String get() = when (this) { PUBLIC -> "public" UNLISTED -> "unlisted" @@ -100,10 +96,8 @@ data class Status( } companion object { - - @JvmStatic - fun byNum(num: Int): Visibility { - return when (num) { + fun fromInt(int: Int): Visibility { + return when (int) { 4 -> DIRECT 3 -> PRIVATE 2 -> UNLISTED @@ -113,8 +107,7 @@ data class Status( } } - @JvmStatic - fun byString(s: String): Visibility { + fun fromStringValue(s: String): Visibility { return when (s) { "public" -> PUBLIC "unlisted" -> UNLISTED diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java deleted file mode 100644 index ac81fb832..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ /dev/null @@ -1,1260 +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.fragment; - -import static com.keylesspalace.tusky.util.StringUtils.isLessThan; - -import android.app.Activity; -import android.content.Context; -import android.content.DialogInterface; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.util.Log; -import android.util.SparseBooleanArray; -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 android.widget.ArrayAdapter; -import android.widget.ListView; -import android.widget.PopupWindow; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.arch.core.util.Function; -import androidx.coordinatorlayout.widget.CoordinatorLayout; -import androidx.core.util.Pair; -import androidx.core.view.MenuProvider; -import androidx.lifecycle.Lifecycle; -import androidx.preference.PreferenceManager; -import androidx.recyclerview.widget.AsyncDifferConfig; -import androidx.recyclerview.widget.AsyncListDiffer; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.ListUpdateCallback; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.SimpleItemAnimator; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; - -import com.google.android.material.appbar.AppBarLayout; -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.adapter.NotificationsAdapter; -import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; -import com.keylesspalace.tusky.appstore.BlockEvent; -import com.keylesspalace.tusky.appstore.EventHub; -import com.keylesspalace.tusky.appstore.PreferenceChangedEvent; -import com.keylesspalace.tusky.appstore.StatusChangedEvent; -import com.keylesspalace.tusky.components.notifications.NotificationHelper; -import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding; -import com.keylesspalace.tusky.db.AccountEntity; -import com.keylesspalace.tusky.db.AccountManager; -import com.keylesspalace.tusky.di.Injectable; -import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Poll; -import com.keylesspalace.tusky.entity.Relationship; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.interfaces.AccountActionListener; -import com.keylesspalace.tusky.interfaces.ActionButtonActivity; -import com.keylesspalace.tusky.interfaces.ReselectableFragment; -import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.settings.PrefKeys; -import com.keylesspalace.tusky.util.CardViewMode; -import com.keylesspalace.tusky.util.Either; -import com.keylesspalace.tusky.util.HttpHeaderLink; -import com.keylesspalace.tusky.util.LinkHelper; -import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; -import com.keylesspalace.tusky.util.ListUtils; -import com.keylesspalace.tusky.util.NotificationTypeConverterKt; -import com.keylesspalace.tusky.util.PairedList; -import com.keylesspalace.tusky.util.RelativeTimeUpdater; -import com.keylesspalace.tusky.util.Single; -import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.util.ViewDataUtils; -import com.keylesspalace.tusky.view.EndlessOnScrollListener; -import com.keylesspalace.tusky.viewdata.AttachmentViewData; -import com.keylesspalace.tusky.viewdata.NotificationViewData; -import com.keylesspalace.tusky.viewdata.StatusViewData; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.Set; - -import javax.inject.Inject; - -import at.connyduck.sparkbutton.helpers.Utils; -import kotlin.Unit; -import kotlin.collections.CollectionsKt; -import kotlin.jvm.functions.Function1; -import kotlin.jvm.functions.Function2; -import kotlinx.coroutines.Job; - -public class NotificationsFragment extends SFragment implements - SwipeRefreshLayout.OnRefreshListener, - StatusActionListener, - NotificationsAdapter.NotificationActionListener, - AccountActionListener, - Injectable, - MenuProvider, - ReselectableFragment { - private static final String TAG = "NotificationF"; // logging tag - - private static final int LOAD_AT_ONCE = 30; - private int maxPlaceholderId = 0; - - private final Set notificationFilter = new HashSet<>(); - - private final ArrayList jobs = new ArrayList<>(); - - private enum FetchEnd { - TOP, - BOTTOM, - MIDDLE - } - - /** - * Placeholder for the notificationsEnabled. Consider moving to the separate class to hide constructor - * and reuse in different places as needed. - */ - private static final class Placeholder { - final long id; - - public static Placeholder getInstance(long id) { - return new Placeholder(id); - } - - private Placeholder(long id) { - this.id = id; - } - } - - @Inject - AccountManager accountManager; - @Inject - EventHub eventHub; - - private FragmentTimelineNotificationsBinding binding; - - private LinearLayoutManager layoutManager; - private EndlessOnScrollListener scrollListener; - private NotificationsAdapter adapter; - private boolean hideFab; - private boolean topLoading; - private boolean bottomLoading; - private String bottomId; - private boolean alwaysShowSensitiveMedia; - private boolean alwaysOpenSpoiler; - private boolean showNotificationsFilter; - private boolean showingError; - - // Each element is either a Notification for loading data or a Placeholder - private final PairedList, NotificationViewData> notifications - = new PairedList<>(new Function<>() { - @Override - public NotificationViewData apply(Either input) { - if (input.isRight()) { - Notification notification = input.asRight() - .rewriteToStatusTypeIfNeeded(accountManager.getActiveAccount().getAccountId()); - - boolean sensitiveStatus = notification.getStatus() != null && notification.getStatus().getActionableStatus().getSensitive(); - - return ViewDataUtils.notificationToViewData( - notification, - alwaysShowSensitiveMedia || !sensitiveStatus, - alwaysOpenSpoiler, - true - ); - } else { - return new NotificationViewData.Placeholder(input.asLeft().id, false); - } - } - }); - - public static NotificationsFragment newInstance() { - NotificationsFragment fragment = new NotificationsFragment(); - Bundle arguments = new Bundle(); - fragment.setArguments(arguments); - return fragment; - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - requireActivity().addMenuProvider(this, getViewLifecycleOwner(), Lifecycle.State.RESUMED); - - binding = FragmentTimelineNotificationsBinding.inflate(inflater, container, false); - - @NonNull Context context = inflater.getContext(); // from inflater to silence warning - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); - - boolean showNotificationsFilterSetting = preferences.getBoolean("showNotificationsFilter", true); - // Clear notifications on filter visibility change to force refresh - if (showNotificationsFilterSetting != showNotificationsFilter) - notifications.clear(); - showNotificationsFilter = showNotificationsFilterSetting; - - // Setup the SwipeRefreshLayout. - binding.swipeRefreshLayout.setOnRefreshListener(this); - binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue); - - loadNotificationsFilter(); - - // Setup the RecyclerView. - binding.recyclerView.setHasFixedSize(true); - layoutManager = new LinearLayoutManager(context); - binding.recyclerView.setLayoutManager(layoutManager); - binding.recyclerView.setAccessibilityDelegateCompat( - new ListStatusAccessibilityDelegate(binding.recyclerView, this, (pos) -> { - NotificationViewData notification = notifications.getPairedItemOrNull(pos); - // We support replies only for now - if (notification instanceof NotificationViewData.Concrete) { - return ((NotificationViewData.Concrete) notification).getStatusViewData(); - } else { - return null; - } - })); - - binding.recyclerView.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.VERTICAL)); - - StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions( - preferences.getBoolean("animateGifAvatars", false), - accountManager.getActiveAccount().getMediaPreviewEnabled(), - preferences.getBoolean("absoluteTimeView", false), - preferences.getBoolean("showBotOverlay", true), - preferences.getBoolean("useBlurhash", true), - CardViewMode.NONE, - preferences.getBoolean("confirmReblogs", true), - preferences.getBoolean("confirmFavourites", false), - preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), - preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), - accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(), - accountManager.getActiveAccount().getAlwaysOpenSpoiler() - ); - - adapter = new NotificationsAdapter(accountManager.getActiveAccount().getAccountId(), - dataSource, statusDisplayOptions, this, this, this); - alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); - alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); - binding.recyclerView.setAdapter(adapter); - - topLoading = false; - bottomLoading = false; - bottomId = null; - - updateAdapter(); - - binding.buttonClear.setOnClickListener(v -> confirmClearNotifications()); - binding.buttonFilter.setOnClickListener(v -> showFilterMenu()); - - if (notifications.isEmpty()) { - binding.swipeRefreshLayout.setEnabled(false); - sendFetchNotificationsRequest(null, null, FetchEnd.BOTTOM, -1); - } else { - binding.progressBar.setVisibility(View.GONE); - } - - ((SimpleItemAnimator) binding.recyclerView.getItemAnimator()).setSupportsChangeAnimations(false); - - updateFilterVisibility(); - - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - @Override - public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) { - menuInflater.inflate(R.menu.fragment_notifications, menu); - } - - @Override - public boolean onMenuItemSelected(@NonNull MenuItem menuItem) { - if (menuItem.getItemId() == R.id.action_refresh) { - binding.swipeRefreshLayout.setRefreshing(true); - onRefresh(); - return true; - } else if (menuItem.getItemId() == R.id.action_edit_notification_filter) { - showFilterMenu(); - return true; - } else if (menuItem.getItemId() == R.id.action_clear_notifications) { - confirmClearNotifications(); - return true; - } - - return false; - } - - private void updateFilterVisibility() { - CoordinatorLayout.LayoutParams params = - (CoordinatorLayout.LayoutParams) binding.swipeRefreshLayout.getLayoutParams(); - if (showNotificationsFilter && !showingError) { - binding.appBarOptions.setExpanded(true, false); - binding.appBarOptions.setVisibility(View.VISIBLE); - // Set content behaviour to hide filter on scroll - params.setBehavior(new AppBarLayout.ScrollingViewBehavior()); - } else { - binding.appBarOptions.setExpanded(false, false); - binding.appBarOptions.setVisibility(View.GONE); - // Clear behaviour to hide app bar - params.setBehavior(null); - } - } - - private void confirmClearNotifications() { - new AlertDialog.Builder(requireContext()) - .setMessage(R.string.notification_clear_text) - .setPositiveButton(android.R.string.ok, (DialogInterface dia, int which) -> clearNotifications()) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - Activity activity = getActivity(); - if (activity == null) throw new AssertionError("Activity is null"); - - // This is delayed until onActivityCreated solely because MainActivity.composeButton - // isn't guaranteed to be set until then. - // Use a modified scroll listener that both loads more notificationsEnabled as it - // goes, and hides the compose button on down-scroll. - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity); - hideFab = preferences.getBoolean("fabHide", false); - scrollListener = new EndlessOnScrollListener(layoutManager) { - @Override - public void onScrolled(@NonNull RecyclerView view, int dx, int dy) { - super.onScrolled(view, dx, dy); - - ActionButtonActivity activity = (ActionButtonActivity) getActivity(); - FloatingActionButton composeButton = activity.getActionButton(); - - if (composeButton != null) { - if (hideFab) { - if (dy > 0 && composeButton.isShown()) { - composeButton.hide(); // Hides the button if we're scrolling down - } else if (dy < 0 && !composeButton.isShown()) { - composeButton.show(); // Shows it if we are scrolling up - } - } else if (!composeButton.isShown()) { - composeButton.show(); - } - } - } - - @Override - public void onLoadMore(int totalItemsCount, @NonNull RecyclerView view) { - NotificationsFragment.this.onLoadMore(); - } - }; - - binding.recyclerView.addOnScrollListener(scrollListener); - - eventHub.subscribe( - getViewLifecycleOwner(), - event -> { - if (event instanceof StatusChangedEvent) { - Status updatedStatus = ((StatusChangedEvent) event).getStatus(); - updateStatus(updatedStatus.getActionableId(), s -> updatedStatus); - } else if (event instanceof BlockEvent) { - removeAllByAccountId(((BlockEvent) event).getAccountId()); - } else if (event instanceof PreferenceChangedEvent) { - onPreferenceChanged(((PreferenceChangedEvent) event).getPreferenceKey()); - } - } - ); - - RelativeTimeUpdater.updateRelativeTimePeriodically(this, this::updateAdapter); - } - - @Override - public void onRefresh() { - binding.statusView.setVisibility(View.GONE); - this.showingError = false; - Either first = CollectionsKt.firstOrNull(this.notifications); - String topId; - if (first != null && first.isRight()) { - topId = first.asRight().getId(); - } else { - topId = null; - } - sendFetchNotificationsRequest(null, topId, FetchEnd.TOP, -1); - } - - @Nullable - @Override - protected Function2 getOnMoreTranslate() { - return null; - } - - @Override - public void onReply(int position) { - super.reply(notifications.get(position).asRight().getStatus()); - } - - @Override - public void onReblog(final boolean reblog, final int position) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus(); - Objects.requireNonNull(status, "Reblog on notification without status"); - timelineCases.reblogOld(status.getId(), reblog) - .subscribe( - getViewLifecycleOwner(), - (newStatus) -> setReblogForStatus(status.getId(), reblog), - (t) -> Log.d(getClass().getSimpleName(), - "Failed to reblog status: " + status.getId(), t) - ); - } - - private void setReblogForStatus(String statusId, boolean reblog) { - updateStatus(statusId, (s) -> s.copyWithReblogged(reblog)); - } - - @Override - public void onFavourite(final boolean favourite, final int position) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus(); - - timelineCases.favouriteOld(status.getId(), favourite) - .subscribe( - getViewLifecycleOwner(), - (newStatus) -> setFavouriteForStatus(status.getId(), favourite), - (t) -> Log.d(getClass().getSimpleName(), - "Failed to favourite status: " + status.getId(), t) - ); - } - - private void setFavouriteForStatus(String statusId, boolean favourite) { - updateStatus(statusId, (s) -> s.copyWithFavourited(favourite)); - } - - @Override - public void onBookmark(final boolean bookmark, final int position) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus(); - - timelineCases.bookmarkOld(status.getActionableId(), bookmark) - .subscribe( - getViewLifecycleOwner(), - (newStatus) -> setBookmarkForStatus(status.getId(), bookmark), - (t) -> Log.d(getClass().getSimpleName(), - "Failed to bookmark status: " + status.getId(), t) - ); - } - - private void setBookmarkForStatus(String statusId, boolean bookmark) { - updateStatus(statusId, (s) -> s.copyWithBookmarked(bookmark)); - } - - public void onVoteInPoll(int position, @NonNull List choices) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus().getActionableStatus(); - timelineCases.voteInPollOld(status.getId(), status.getPoll().getId(), choices) - .subscribe( - getViewLifecycleOwner(), - (newPoll) -> setVoteForPoll(status, newPoll), - (t) -> Log.d(TAG, "Failed to vote in poll: " + status.getId(), t) - ); - } - - @Override - public void clearWarningAction(int position) { - - } - - private void setVoteForPoll(Status status, Poll poll) { - updateStatus(status.getId(), (s) -> s.copyWithPoll(poll)); - } - - @Override - public void onMore(@NonNull View view, int position) { - Notification notification = notifications.get(position).asRight(); - super.more(notification.getStatus(), view, position, null); - } - - @Override - public void onViewMedia(int position, int attachmentIndex, @Nullable View view) { - Notification notification = notifications.get(position).asRightOrNull(); - if (notification == null || notification.getStatus() == null) return; - Status status = notification.getStatus(); - super.viewMedia(attachmentIndex, AttachmentViewData.list(status, accountManager.getActiveAccount().getAlwaysShowSensitiveMedia()), view); - } - - @Override - public void onViewThread(int position) { - Notification notification = notifications.get(position).asRight(); - Status status = notification.getStatus(); - if (status == null) return; - super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); - } - - @Override - public void onOpenReblog(int position) { - Notification notification = notifications.get(position).asRight(); - onViewAccount(notification.getAccount().getId()); - } - - @Override - public void onExpandedChange(boolean expanded, int position) { - updateViewDataAt(position, (vd) -> vd.copyWithExpanded(expanded)); - } - - @Override - public void onContentHiddenChange(boolean isShowing, int position) { - updateViewDataAt(position, (vd) -> vd.copyWithShowingContent(isShowing)); - } - - @Override - public void onLoadMore(int position) { - // Check bounds before accessing list, - if (notifications.size() >= position && position > 0) { - Notification previous = notifications.get(position - 1).asRightOrNull(); - Notification next = notifications.get(position + 1).asRightOrNull(); - if (previous == null || next == null) { - Log.e(TAG, "Failed to load more, invalid placeholder position: " + position); - return; - } - sendFetchNotificationsRequest(previous.getId(), next.getId(), FetchEnd.MIDDLE, position); - Placeholder placeholder = notifications.get(position).asLeft(); - NotificationViewData notificationViewData = - new NotificationViewData.Placeholder(placeholder.id, true); - notifications.setPairedItem(position, notificationViewData); - updateAdapter(); - } else { - Log.d(TAG, "error loading more"); - } - } - - @Override - public void onContentCollapsedChange(boolean isCollapsed, int position) { - updateViewDataAt(position, (vd) -> vd.copyWithCollapsed(isCollapsed)); - } - - @Override - public void onUntranslate(int position) { - // not needed - } - - private void updateStatus(String statusId, Function mapper) { - int index = CollectionsKt.indexOfFirst(this.notifications, (s) -> s.isRight() && - s.asRight().getStatus() != null && - s.asRight().getStatus().getId().equals(statusId)); - if (index == -1) return; - - // We have quite some graph here: - // - // Notification --------> Status - // ^ - // | - // StatusViewData - // ^ - // | - // NotificationViewData -----+ - // - // So if we have "new" status we need to update all references to be sure that data is - // up-to-date: - // 1. update status - // 2. update notification - // 3. update statusViewData - // 4. update notificationViewData - - Status oldStatus = notifications.get(index).asRight().getStatus(); - NotificationViewData.Concrete oldViewData = - (NotificationViewData.Concrete) this.notifications.getPairedItem(index); - Status newStatus = mapper.apply(oldStatus); - Notification newNotification = this.notifications.get(index).asRight() - .copyWithStatus(newStatus); - StatusViewData.Concrete newStatusViewData = - Objects.requireNonNull(oldViewData.getStatusViewData()).copyWithStatus(newStatus); - NotificationViewData.Concrete newViewData = oldViewData.copyWithStatus(newStatusViewData); - - notifications.set(index, new Either.Right<>(newNotification)); - notifications.setPairedItem(index, newViewData); - - updateAdapter(); - } - - private void updateViewDataAt(int position, - Function mapper) { - if (position < 0 || position >= notifications.size()) { - String message = String.format( - Locale.getDefault(), - "Tried to access out of bounds status position: %d of %d", - position, - notifications.size() - 1 - ); - Log.e(TAG, message); - return; - } - NotificationViewData someViewData = this.notifications.getPairedItem(position); - if (!(someViewData instanceof NotificationViewData.Concrete)) { - return; - } - NotificationViewData.Concrete oldViewData = (NotificationViewData.Concrete) someViewData; - StatusViewData.Concrete oldStatusViewData = oldViewData.getStatusViewData(); - if (oldStatusViewData == null) return; - - NotificationViewData.Concrete newViewData = - oldViewData.copyWithStatus(mapper.apply(oldStatusViewData)); - notifications.setPairedItem(position, newViewData); - - updateAdapter(); - } - - @Override - public void onNotificationContentCollapsedChange(boolean isCollapsed, int position) { - onContentCollapsedChange(isCollapsed, position); - } - - private void clearNotifications() { - // Cancel all ongoing requests - binding.swipeRefreshLayout.setRefreshing(false); - resetNotificationsLoad(); - - // Show friend elephant - binding.statusView.setVisibility(View.VISIBLE); - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); - updateFilterVisibility(); - - // Update adapter - updateAdapter(); - - // Execute clear notifications request - timelineCases.clearNotificationsOld() - .subscribe( - getViewLifecycleOwner(), - response -> { - // Nothing to do - }, - throwable -> { - // Reload notifications on failure - fullyRefreshWithProgressBar(true); - }); - } - - private void resetNotificationsLoad() { - for (Job job : jobs) { - job.cancel(null); - } - jobs.clear(); - bottomLoading = false; - topLoading = false; - - // Disable load more - bottomId = null; - - // Clear exists notifications - notifications.clear(); - } - - - private void showFilterMenu() { - List notificationsList = Notification.Type.Companion.getVisibleTypes(); - List list = new ArrayList<>(); - for (Notification.Type type : notificationsList) { - list.add(getNotificationText(type)); - } - - ArrayAdapter adapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_list_item_multiple_choice, list); - PopupWindow window = new PopupWindow(getContext()); - View view = LayoutInflater.from(getContext()).inflate(R.layout.notifications_filter, (ViewGroup) getView(), false); - final ListView listView = view.findViewById(R.id.listView); - view.findViewById(R.id.buttonApply) - .setOnClickListener(v -> { - SparseBooleanArray checkedItems = listView.getCheckedItemPositions(); - Set excludes = new HashSet<>(); - for (int i = 0; i < notificationsList.size(); i++) { - if (!checkedItems.get(i, false)) - excludes.add(notificationsList.get(i)); - } - window.dismiss(); - applyFilterChanges(excludes); - - }); - - listView.setAdapter(adapter); - listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); - for (int i = 0; i < notificationsList.size(); i++) { - if (!notificationFilter.contains(notificationsList.get(i))) - listView.setItemChecked(i, true); - } - window.setContentView(view); - window.setFocusable(true); - window.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT); - window.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - window.showAsDropDown(binding.buttonFilter); - - } - - private String getNotificationText(Notification.Type type) { - switch (type) { - case MENTION: - return getString(R.string.notification_mention_name); - case FAVOURITE: - return getString(R.string.notification_favourite_name); - case REBLOG: - return getString(R.string.notification_boost_name); - case FOLLOW: - return getString(R.string.notification_follow_name); - case FOLLOW_REQUEST: - return getString(R.string.notification_follow_request_name); - case POLL: - return getString(R.string.notification_poll_name); - case STATUS: - return getString(R.string.notification_subscription_name); - case SIGN_UP: - return getString(R.string.notification_sign_up_name); - case UPDATE: - return getString(R.string.notification_update_name); - case REPORT: - return getString(R.string.notification_report_name); - default: - return "Unknown"; - } - } - - private void applyFilterChanges(Set newSet) { - List notifications = Notification.Type.Companion.getVisibleTypes(); - boolean isChanged = false; - for (Notification.Type type : notifications) { - if (notificationFilter.contains(type) && !newSet.contains(type)) { - notificationFilter.remove(type); - isChanged = true; - } else if (!notificationFilter.contains(type) && newSet.contains(type)) { - notificationFilter.add(type); - isChanged = true; - } - } - if (isChanged) { - saveNotificationsFilter(); - fullyRefreshWithProgressBar(true); - } - - } - - private void loadNotificationsFilter() { - AccountEntity account = accountManager.getActiveAccount(); - if (account != null) { - notificationFilter.clear(); - notificationFilter.addAll(NotificationTypeConverterKt.deserialize( - account.getNotificationsFilter())); - } - } - - private void saveNotificationsFilter() { - AccountEntity account = accountManager.getActiveAccount(); - if (account != null) { - account.setNotificationsFilter(NotificationTypeConverterKt.serialize(notificationFilter)); - accountManager.saveAccount(account); - } - } - - @Override - public void onViewTag(@NonNull String tag) { - super.viewTag(tag); - } - - @Override - public void onViewAccount(@NonNull String id) { - super.viewAccount(id); - } - - @Override - public void onMute(boolean mute, String id, int position, boolean notifications) { - // No muting from notifications yet - } - - @Override - public void onBlock(boolean block, String id, int position) { - // No blocking from notifications yet - } - - @Override - public void onRespondToFollowRequest(boolean accept, String id, int position) { - final Single request = accept ? - timelineCases.acceptFollowRequestOld(id) : - timelineCases.rejectFollowRequestOld(id); - request.subscribe( - getViewLifecycleOwner(), - (relationship) -> fullyRefreshWithProgressBar(true), - (error) -> Log.e(TAG, String.format("Failed to %s account id %s", accept ? "accept" : "reject", id)) - ); - } - - @Override - public void onViewStatusForNotificationId(String notificationId) { - for (Either either : notifications) { - Notification notification = either.asRightOrNull(); - if (notification != null && notification.getId().equals(notificationId)) { - Status status = notification.getStatus(); - if (status != null) { - super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); - return; - } - } - } - Log.w(TAG, "Didn't find a notification for ID: " + notificationId); - } - - @Override - public void onViewReport(String reportId) { - LinkHelper.openLink(requireContext(), String.format("https://%s/admin/reports/%s", accountManager.getActiveAccount().getDomain(), reportId)); - } - - private void onPreferenceChanged(String key) { - switch (key) { - case "fabHide": { - hideFab = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("fabHide", false); - break; - } - case "mediaPreviewEnabled": { - boolean enabled = accountManager.getActiveAccount().getMediaPreviewEnabled(); - if (enabled != adapter.isMediaPreviewEnabled()) { - adapter.setMediaPreviewEnabled(enabled); - fullyRefresh(); - } - break; - } - case "showNotificationsFilter": { - if (isAdded()) { - showNotificationsFilter = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("showNotificationsFilter", true); - updateFilterVisibility(); - fullyRefreshWithProgressBar(true); - } - break; - } - } - } - - @Override - public void removeItem(int position) { - notifications.remove(position); - updateAdapter(); - } - - private void removeAllByAccountId(String accountId) { - // Using iterator to safely remove items while iterating - Iterator> iterator = notifications.iterator(); - while (iterator.hasNext()) { - Either notification = iterator.next(); - Notification maybeNotification = notification.asRightOrNull(); - if (maybeNotification != null && maybeNotification.getAccount().getId().equals(accountId)) { - iterator.remove(); - } - } - updateAdapter(); - } - - private void onLoadMore() { - if (bottomId == null) { - // Already loaded everything - return; - } - - // Check for out-of-bounds when loading - // This is required to allow full-timeline reloads of collapsible statuses when the settings - // change. - if (notifications.size() > 0) { - Either last = notifications.get(notifications.size() - 1); - if (last.isRight()) { - final Placeholder placeholder = newPlaceholder(); - notifications.add(new Either.Left<>(placeholder)); - NotificationViewData viewData = - new NotificationViewData.Placeholder(placeholder.id, true); - notifications.setPairedItem(notifications.size() - 1, viewData); - updateAdapter(); - } - } - - sendFetchNotificationsRequest(bottomId, null, FetchEnd.BOTTOM, -1); - } - - private Placeholder newPlaceholder() { - Placeholder placeholder = Placeholder.getInstance(maxPlaceholderId); - maxPlaceholderId--; - return placeholder; - } - - private void jumpToTop() { - if (isAdded()) { - //binding.appBarOptions.setExpanded(true, false); - layoutManager.scrollToPosition(0); - scrollListener.reset(); - } - } - - private void sendFetchNotificationsRequest(String fromId, String uptoId, - final FetchEnd fetchEnd, final int pos) { - // If there is a fetch already ongoing, record however many fetches are requested and - // fulfill them after it's complete. - if (fetchEnd == FetchEnd.TOP && topLoading) { - return; - } - if (fetchEnd == FetchEnd.BOTTOM && bottomLoading) { - return; - } - if (fetchEnd == FetchEnd.TOP) { - topLoading = true; - } - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = true; - } - - Job notificationCall = timelineCases.notificationsOld(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null) - .subscribe( - getViewLifecycleOwner(), - response -> { - if (response.isSuccessful()) { - String linkHeader = response.headers().get("Link"); - onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd, pos); - } else { - onFetchNotificationsFailure(new Exception(response.message()), fetchEnd, pos); - } - }, - throwable -> onFetchNotificationsFailure(throwable, fetchEnd, pos) - ); - jobs.add(notificationCall); - } - - private void onFetchNotificationsSuccess(List notifications, String linkHeader, - FetchEnd fetchEnd, int pos) { - List links = HttpHeaderLink.Companion.parse(linkHeader); - HttpHeaderLink next = HttpHeaderLink.Companion.findByRelationType(links, "next"); - String fromId = null; - if (next != null) { - fromId = next.getUri().getQueryParameter("max_id"); - } - - switch (fetchEnd) { - case TOP: { - update(notifications, this.notifications.isEmpty() ? fromId : null); - break; - } - case MIDDLE: { - replacePlaceholderWithNotifications(notifications, pos); - break; - } - case BOTTOM: { - - if (!this.notifications.isEmpty() - && !this.notifications.get(this.notifications.size() - 1).isRight()) { - this.notifications.remove(this.notifications.size() - 1); - updateAdapter(); - } - - if (adapter.getItemCount() > 1) { - addItems(notifications, fromId); - } else { - update(notifications, fromId); - } - - break; - } - } - - saveNewestNotificationId(notifications); - - if (fetchEnd == FetchEnd.TOP) { - topLoading = false; - } - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = false; - } - - if (notifications.size() == 0 && adapter.getItemCount() == 0) { - binding.statusView.setVisibility(View.VISIBLE); - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); - } - - updateFilterVisibility(); - binding.swipeRefreshLayout.setEnabled(true); - binding.swipeRefreshLayout.setRefreshing(false); - binding.progressBar.setVisibility(View.GONE); - } - - private void onFetchNotificationsFailure(Throwable throwable, FetchEnd fetchEnd, int position) { - binding.swipeRefreshLayout.setRefreshing(false); - if (fetchEnd == FetchEnd.MIDDLE && !notifications.get(position).isRight()) { - Placeholder placeholder = notifications.get(position).asLeft(); - NotificationViewData placeholderVD = - new NotificationViewData.Placeholder(placeholder.id, false); - notifications.setPairedItem(position, placeholderVD); - updateAdapter(); - } else if (this.notifications.isEmpty()) { - binding.statusView.setVisibility(View.VISIBLE); - binding.swipeRefreshLayout.setEnabled(false); - this.showingError = true; - if (throwable instanceof IOException) { - binding.statusView.setup(R.drawable.errorphant_offline, R.string.error_network, __ -> { - binding.progressBar.setVisibility(View.VISIBLE); - this.onRefresh(); - return Unit.INSTANCE; - }); - } else { - binding.statusView.setup(R.drawable.errorphant_error, R.string.error_generic, __ -> { - binding.progressBar.setVisibility(View.VISIBLE); - this.onRefresh(); - return Unit.INSTANCE; - }); - } - updateFilterVisibility(); - } - Log.e(TAG, "Fetch failure: " + throwable.getMessage()); - - if (fetchEnd == FetchEnd.TOP) { - topLoading = false; - } - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = false; - } - - binding.progressBar.setVisibility(View.GONE); - } - - private void saveNewestNotificationId(List notifications) { - - AccountEntity account = accountManager.getActiveAccount(); - if (account != null) { - String lastNotificationId = account.getLastNotificationId(); - - for (Notification noti : notifications) { - if (isLessThan(lastNotificationId, noti.getId())) { - lastNotificationId = noti.getId(); - } - } - - if (!account.getLastNotificationId().equals(lastNotificationId)) { - Log.d(TAG, "saving newest noti id: " + lastNotificationId); - account.setLastNotificationId(lastNotificationId); - accountManager.saveAccount(account); - } - } - } - - private void update(@Nullable List newNotifications, @Nullable String fromId) { - if (ListUtils.isEmpty(newNotifications)) { - updateAdapter(); - return; - } - if (fromId != null) { - bottomId = fromId; - } - List> liftedNew = - liftNotificationList(newNotifications); - if (notifications.isEmpty()) { - notifications.addAll(liftedNew); - } else { - int index = notifications.indexOf(liftedNew.get(newNotifications.size() - 1)); - if (index > 0) { - notifications.subList(0, index).clear(); - } - - int newIndex = liftedNew.indexOf(notifications.get(0)); - if (newIndex == -1) { - if (index == -1 && liftedNew.size() >= LOAD_AT_ONCE) { - liftedNew.add(new Either.Left<>(newPlaceholder())); - } - notifications.addAll(0, liftedNew); - } else { - notifications.addAll(0, liftedNew.subList(0, newIndex)); - } - } - updateAdapter(); - } - - private void addItems(List newNotifications, @Nullable String fromId) { - bottomId = fromId; - if (ListUtils.isEmpty(newNotifications)) { - return; - } - int end = notifications.size(); - List> liftedNew = liftNotificationList(newNotifications); - Either last = notifications.get(end - 1); - if (last != null && !liftedNew.contains(last)) { - notifications.addAll(liftedNew); - updateAdapter(); - } - } - - private void replacePlaceholderWithNotifications(List newNotifications, int pos) { - // Remove placeholder - notifications.remove(pos); - - if (ListUtils.isEmpty(newNotifications)) { - updateAdapter(); - return; - } - - List> liftedNew = liftNotificationList(newNotifications); - - // If we fetched less posts than in the limit, it means that the hole is not filled - // If we fetched at least as much it means that there are more posts to load and we should - // insert new placeholder - if (newNotifications.size() >= LOAD_AT_ONCE) { - liftedNew.add(new Either.Left<>(newPlaceholder())); - } - - notifications.addAll(pos, liftedNew); - updateAdapter(); - } - - private final Function1> notificationLifter = - Either.Right::new; - - private List> liftNotificationList(List list) { - return CollectionsKt.map(list, notificationLifter); - } - - private void fullyRefreshWithProgressBar(boolean isShow) { - resetNotificationsLoad(); - if (isShow) { - binding.progressBar.setVisibility(View.VISIBLE); - binding.statusView.setVisibility(View.GONE); - } - updateAdapter(); - sendFetchNotificationsRequest(null, null, FetchEnd.TOP, -1); - } - - private void fullyRefresh() { - fullyRefreshWithProgressBar(false); - } - - @Nullable - private Pair findReplyPosition(@NonNull String statusId) { - for (int i = 0; i < notifications.size(); i++) { - Notification notification = notifications.get(i).asRightOrNull(); - if (notification != null - && notification.getStatus() != null - && notification.getType() == Notification.Type.MENTION - && (statusId.equals(notification.getStatus().getId()) - || (notification.getStatus().getReblog() != null - && statusId.equals(notification.getStatus().getReblog().getId())))) { - return new Pair<>(i, notification); - } - } - return null; - } - - private void updateAdapter() { - differ.submitList(notifications.getPairedCopy()); - } - - private final ListUpdateCallback listUpdateCallback = new ListUpdateCallback() { - @Override - public void onInserted(int position, int count) { - if (isAdded()) { - adapter.notifyItemRangeInserted(position, count); - Context context = getContext(); - // scroll up when new items at the top are loaded while being at the start - // https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724 - if (position == 0 && context != null && adapter.getItemCount() != count) { - binding.recyclerView.scrollBy(0, Utils.dpToPx(context, -30)); - } - } - } - - @Override - public void onRemoved(int position, int count) { - adapter.notifyItemRangeRemoved(position, count); - } - - @Override - public void onMoved(int fromPosition, int toPosition) { - adapter.notifyItemMoved(fromPosition, toPosition); - } - - @Override - public void onChanged(int position, int count, Object payload) { - adapter.notifyItemRangeChanged(position, count, payload); - } - }; - - private final AsyncListDiffer - differ = new AsyncListDiffer<>(listUpdateCallback, - new AsyncDifferConfig.Builder<>(diffCallback).build()); - - private final NotificationsAdapter.AdapterDataSource dataSource = - new NotificationsAdapter.AdapterDataSource<>() { - @Override - public int getItemCount() { - return differ.getCurrentList().size(); - } - - @Override - public NotificationViewData getItemAt(int pos) { - return differ.getCurrentList().get(pos); - } - }; - - private static final DiffUtil.ItemCallback diffCallback - = new DiffUtil.ItemCallback<>() { - - @Override - public boolean areItemsTheSame(NotificationViewData oldItem, NotificationViewData newItem) { - return oldItem.getViewDataId() == newItem.getViewDataId(); - } - - @Override - public boolean areContentsTheSame(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) { - return false; - } - - @Nullable - @Override - public Object getChangePayload(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) { - if (oldItem.deepEquals(newItem)) { - // If items are equal - update timestamp only - return Collections.singletonList(StatusBaseViewHolder.Key.KEY_CREATED); - } else - // If items are different - update a whole view holder - return null; - } - }; - - @Override - public void onResume() { - super.onResume(); - - NotificationHelper.clearNotificationsForAccount(requireContext(), accountManager.getActiveAccount()); - - String rawAccountNotificationFilter = accountManager.getActiveAccount().getNotificationsFilter(); - Set accountNotificationFilter = NotificationTypeConverterKt.deserialize(rawAccountNotificationFilter); - if (!notificationFilter.equals(accountNotificationFilter)) { - loadNotificationsFilter(); - fullyRefreshWithProgressBar(true); - } - } - - @Override - public void onReselect() { - jumpToTop(); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt index 110a99e95..ca6ca4c89 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt @@ -16,26 +16,26 @@ package com.keylesspalace.tusky.fragment import android.Manifest import android.app.DownloadManager -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import android.content.DialogInterface import android.content.Intent -import android.content.pm.PackageManager import android.net.Uri import android.os.Build +import android.os.Bundle import android.os.Environment import android.util.Log import android.view.MenuItem import android.view.View import android.widget.Toast -import androidx.appcompat.app.AlertDialog +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.LayoutRes import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityOptionsCompat +import androidx.core.content.getSystemService import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.onFailure +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BottomSheetActivity @@ -48,15 +48,15 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.star import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.components.report.ReportActivity.Companion.getIntent -import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Translation import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.copyToClipboard import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation @@ -72,14 +72,16 @@ import kotlinx.coroutines.launch * adapters. I feel like the profile pages and thread viewer, which I haven't made yet, will also * overlap functionality. So, I'm momentarily leaving it and hopefully working on those will clear * up what needs to be where. */ -abstract class SFragment : Fragment(), Injectable { +abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayoutId) { protected abstract fun removeItem(position: Int) - protected abstract fun onReblog(reblog: Boolean, position: Int) + protected abstract fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) /** `null` if translation is not supported on this screen */ protected abstract val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit)? - private lateinit var bottomSheetActivity: BottomSheetActivity + private val bottomSheetActivity: BottomSheetActivity + get() = (requireActivity() as? BottomSheetActivity) + ?: throw IllegalStateException("Fragment must be attached to a BottomSheetActivity!") @Inject lateinit var mastodonApi: MastodonApi @@ -93,16 +95,35 @@ abstract class SFragment : Fragment(), Injectable { @Inject lateinit var instanceInfoRepository: InstanceInfoRepository + private var pendingMediaDownloads: List? = null + + private val downloadAllMediaPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) { + pendingMediaDownloads?.let { downloadAllMedia(it) } + } else { + Toast.makeText( + context, + R.string.error_media_download_permission, + Toast.LENGTH_SHORT + ).show() + } + pendingMediaDownloads = null + } + override fun startActivity(intent: Intent) { requireActivity().startActivityWithSlideInAnimation(intent) } - override fun onAttach(context: Context) { - super.onAttach(context) - bottomSheetActivity = if (context is BottomSheetActivity) { - context - } else { - throw IllegalStateException("Fragment must be attached to a BottomSheetActivity!") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + pendingMediaDownloads = savedInstanceState?.getStringArrayList(PENDING_MEDIA_DOWNLOADS_STATE_KEY) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + pendingMediaDownloads?.let { + outState.putStringArrayList(PENDING_MEDIA_DOWNLOADS_STATE_KEY, ArrayList(it)) } } @@ -159,9 +180,10 @@ abstract class SFragment : Fragment(), Injectable { protected fun more(status: Status, view: View, position: Int, translation: Translation?) { val id = status.actionableId - val accountId = status.actionableStatus.account.id - val accountUsername = status.actionableStatus.account.username - val statusUrl = status.actionableStatus.url + val actionableStatus = status.actionableStatus + val accountId = actionableStatus.account.id + val accountUsername = actionableStatus.account.username + val statusUrl = actionableStatus.url var loggedInAccountId: String? = null val activeAccount = accountManager.activeAccount if (activeAccount != null) { @@ -173,22 +195,21 @@ abstract class SFragment : Fragment(), Injectable { if (statusIsByCurrentUser) { popup.inflate(R.menu.status_more_for_user) val menu = popup.menu - when (status.visibility) { + when (actionableStatus.visibility) { Status.Visibility.PUBLIC, Status.Visibility.UNLISTED -> { menu.add( 0, R.id.pin, 1, getString( - if (status.pinned) R.string.unpin_action else R.string.pin_action + if (actionableStatus.pinned) R.string.unpin_action else R.string.pin_action ) ) } Status.Visibility.PRIVATE -> { - val reblogged = status.reblog?.reblogged ?: status.reblogged - menu.findItem(R.id.status_reblog_private).isVisible = !reblogged - menu.findItem(R.id.status_unreblog_private).isVisible = reblogged + menu.findItem(R.id.status_reblog_private).isVisible = !actionableStatus.reblogged + menu.findItem(R.id.status_unreblog_private).isVisible = actionableStatus.reblogged } else -> {} @@ -220,11 +241,12 @@ abstract class SFragment : Fragment(), Injectable { ) } - // translation not there for your own posts + // translation not there for posts already in your language or non-public posts menu.findItem(R.id.status_translate)?.let { translateItem -> translateItem.isVisible = onMoreTranslate != null && !status.language.equals(Locale.getDefault().language, ignoreCase = true) && - instanceInfoRepository.cachedInstanceInfoOrFallback.translationEnabled == true + instanceInfoRepository.cachedInstanceInfoOrFallback.translationEnabled == true && + (status.visibility == Status.Visibility.PUBLIC || status.visibility == Status.Visibility.UNLISTED) translateItem.setTitle(if (translation != null) R.string.action_show_original else R.string.action_translate) } @@ -266,13 +288,7 @@ abstract class SFragment : Fragment(), Injectable { } R.id.status_copy_link -> { - ( - requireActivity().getSystemService( - Context.CLIPBOARD_SERVICE - ) as ClipboardManager - ).apply { - setPrimaryClip(ClipData.newPlainText(null, statusUrl)) - } + statusUrl?.let { requireActivity().copyToClipboard(it, getString(R.string.url_copied)) } return@setOnMenuItemClickListener true } @@ -282,7 +298,7 @@ abstract class SFragment : Fragment(), Injectable { } R.id.status_download_media -> { - requestDownloadAllMedia(status) + requestDownloadAllMedia(actionableStatus) return@setOnMenuItemClickListener true } @@ -302,12 +318,12 @@ abstract class SFragment : Fragment(), Injectable { } R.id.status_unreblog_private -> { - onReblog(false, position) + onReblog(false, position, Status.Visibility.PUBLIC) return@setOnMenuItemClickListener true } R.id.status_reblog_private -> { - onReblog(true, position) + onReblog(true, position, Status.Visibility.PUBLIC) return@setOnMenuItemClickListener true } @@ -327,8 +343,8 @@ abstract class SFragment : Fragment(), Injectable { } R.id.pin -> { - lifecycleScope.launch { - timelineCases.pin(status.id, !status.pinned) + viewLifecycleOwner.lifecycleScope.launch { + timelineCases.pin(status.actionableId, !actionableStatus.pinned) .onFailure { e: Throwable -> val message = e.message ?: getString(if (status.pinned) R.string.failed_to_unpin else R.string.failed_to_pin) @@ -359,15 +375,15 @@ abstract class SFragment : Fragment(), Injectable { showMuteAccountDialog( this.requireActivity(), accountUsername - ) { notifications: Boolean?, duration: Int? -> + ) { notifications: Boolean, duration: Int? -> lifecycleScope.launch { - timelineCases.mute(accountId, notifications == true, duration) + timelineCases.mute(accountId, notifications, duration) } } } private fun onBlock(accountId: String, accountUsername: String) { - AlertDialog.Builder(requireContext()) + MaterialAlertDialogBuilder(requireContext()) .setMessage(getString(R.string.dialog_block_warning, accountUsername)) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> lifecycleScope.launch { @@ -398,7 +414,7 @@ abstract class SFragment : Fragment(), Injectable { } Attachment.Type.UNKNOWN -> { - requireContext().openLink(attachment.url) + requireContext().openLink(attachment.unknownUrl) } } } @@ -412,14 +428,14 @@ abstract class SFragment : Fragment(), Injectable { } private fun showConfirmDeleteDialog(id: String, position: Int) { - AlertDialog.Builder(requireActivity()) + MaterialAlertDialogBuilder(requireActivity()) .setMessage(R.string.dialog_delete_post_warning) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> - lifecycleScope.launch { + viewLifecycleOwner.lifecycleScope.launch { val result = timelineCases.delete(id).exceptionOrNull() if (result != null) { Log.w("SFragment", "error deleting status", result) - Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), R.string.error_generic, Toast.LENGTH_SHORT).show() } // XXX: Removes the item even if there was an error. This is probably not // correct (see similar code in showConfirmEditDialog() which only @@ -434,13 +450,12 @@ abstract class SFragment : Fragment(), Injectable { } private fun showConfirmEditDialog(id: String, position: Int, status: Status) { - if (activity == null) { - return - } - AlertDialog.Builder(requireActivity()) + val context = context ?: return + + MaterialAlertDialogBuilder(context) .setMessage(R.string.dialog_redraft_post_warning) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> - lifecycleScope.launch { + viewLifecycleOwner.lifecycleScope.launch { timelineCases.delete(id).fold( { deletedStatus -> removeItem(position) @@ -461,7 +476,7 @@ abstract class SFragment : Fragment(), Injectable { poll = sourceStatus.poll?.toNewPoll(sourceStatus.createdAt), kind = ComposeActivity.ComposeKind.NEW ) - startActivity(startIntent(requireContext(), composeOptions)) + startActivity(startIntent(context, composeOptions)) }, { error: Throwable? -> Log.w("SFragment", "error deleting status", error) @@ -476,7 +491,7 @@ abstract class SFragment : Fragment(), Injectable { } private fun editStatus(id: String, status: Status) { - lifecycleScope.launch { + viewLifecycleOwner.lifecycleScope.launch { mastodonApi.statusSource(id).fold( { source -> val composeOptions = ComposeOptions( @@ -522,13 +537,11 @@ abstract class SFragment : Fragment(), Injectable { } } - private fun downloadAllMedia(status: Status) { + private fun downloadAllMedia(mediaUrls: List) { Toast.makeText(context, R.string.downloading_media, Toast.LENGTH_SHORT).show() - val downloadManager = requireActivity().getSystemService( - Context.DOWNLOAD_SERVICE - ) as DownloadManager + val downloadManager: DownloadManager = requireContext().getSystemService()!! - for ((_, url) in status.attachments) { + for (url in mediaUrls) { val uri = Uri.parse(url) downloadManager.enqueue( DownloadManager.Request(uri).apply { @@ -542,26 +555,22 @@ abstract class SFragment : Fragment(), Injectable { } private fun requestDownloadAllMedia(status: Status) { + if (status.attachments.isEmpty()) { + return + } + val mediaUrls = status.attachments.map { it.url } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - val permissions = arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE) - (activity as BaseActivity).requestPermissions(permissions) { _, grantResults -> - if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - downloadAllMedia(status) - } else { - Toast.makeText( - context, - R.string.error_media_download_permission, - Toast.LENGTH_SHORT - ).show() - } - } + pendingMediaDownloads = mediaUrls + downloadAllMediaPermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) } else { - downloadAllMedia(status) + downloadAllMedia(mediaUrls) } } companion object { private const val TAG = "SFragment" + private const val PENDING_MEDIA_DOWNLOADS_STATE_KEY = "pending_media_downloads" + private fun accountIsInMentions( account: AccountEntity?, mentions: List diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt index 6a18eca2e..6faf739c2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt @@ -18,7 +18,6 @@ package com.keylesspalace.tusky.fragment import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.annotation.SuppressLint -import android.content.Context import android.graphics.PointF import android.graphics.drawable.Drawable import android.os.Bundle @@ -28,19 +27,21 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.ImageView -import androidx.core.os.BundleCompat -import androidx.core.view.GestureDetectorCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat.Type.systemBars +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import com.bumptech.glide.Glide import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target +import com.google.android.material.bottomsheet.BottomSheetBehavior import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.databinding.FragmentViewImageBinding import com.keylesspalace.tusky.entity.Attachment -import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.getParcelableCompat import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import com.ortiz.touchview.OnTouchCoordinatesListener @@ -58,8 +59,8 @@ class ViewImageFragment : ViewMediaFragment() { private val binding by viewBinding(FragmentViewImageBinding::bind) - private lateinit var photoActionsListener: PhotoActionsListener - private lateinit var toolbar: View + private val photoActionsListener: PhotoActionsListener + get() = requireActivity() as PhotoActionsListener private var transition: CompletableDeferred? = null private var shouldStartTransition = false @@ -68,11 +69,6 @@ class ViewImageFragment : ViewMediaFragment() { @Volatile private var startedTransition = false - override fun onAttach(context: Context) { - super.onAttach(context) - photoActionsListener = context as PhotoActionsListener - } - override fun setupMediaView( url: String, previewUrl: String?, @@ -92,7 +88,6 @@ class ViewImageFragment : ViewMediaFragment() { container: ViewGroup?, savedInstanceState: Bundle? ): View { - toolbar = (requireActivity() as ViewMediaActivity).toolbar this.transition = CompletableDeferred() return inflater.inflate(R.layout.fragment_view_image, container, false) } @@ -101,12 +96,8 @@ class ViewImageFragment : ViewMediaFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val arguments = this.requireArguments() - val attachment = BundleCompat.getParcelable( - arguments, - ARG_ATTACHMENT, - Attachment::class.java - ) + val arguments = requireArguments() + val attachment = arguments.getParcelableCompat(ARG_ATTACHMENT) this.shouldStartTransition = arguments.getBoolean(ARG_START_POSTPONED_TRANSITION) val url: String? var description: String? = null @@ -121,12 +112,27 @@ class ViewImageFragment : ViewMediaFragment() { } } - val singleTapDetector = GestureDetectorCompat( + val descriptionBottomSheet = BottomSheetBehavior.from(binding.captionSheet) + + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets -> + val bottomInsets = insets.getInsets(systemBars()).bottom + val mediaDescriptionBottomPadding = requireContext().resources.getDimensionPixelSize(R.dimen.media_description_sheet_bottom_padding) + val mediaDescriptionPeekHeight = requireContext().resources.getDimensionPixelSize(R.dimen.media_description_sheet_peek_height) + val imageViewBottomMargin = requireContext().resources.getDimensionPixelSize(R.dimen.media_image_view_bottom_margin) + binding.mediaDescription.updatePadding(bottom = mediaDescriptionBottomPadding + bottomInsets) + descriptionBottomSheet.setPeekHeight(mediaDescriptionPeekHeight + bottomInsets, false) + binding.photoView.updateLayoutParams { bottomMargin = imageViewBottomMargin + bottomInsets } + insets.inset(0, 0, 0, bottomInsets) + } + + val singleTapDetector = GestureDetector( requireContext(), object : GestureDetector.SimpleOnGestureListener() { override fun onDown(e: MotionEvent) = true override fun onSingleTapConfirmed(e: MotionEvent): Boolean { - photoActionsListener.onPhotoTap() + if (isAdded) { + photoActionsListener.onPhotoTap() + } return false } } @@ -232,7 +238,7 @@ class ViewImageFragment : ViewMediaFragment() { } override fun onToolbarVisibilityChange(visible: Boolean) { - if (!userVisibleHint) return + if (view == null) return isDescriptionVisible = showingDescription && visible val alpha = if (isDescriptionVisible) 1.0f else 0.0f @@ -343,8 +349,10 @@ class ViewImageFragment : ViewMediaFragment() { // post() because load() replaces image with null. Sometimes after we set // the thumbnail. binding.photoView.post { - target.onResourceReady(resource, null) - if (shouldStartTransition) photoActionsListener.onBringUp() + if (isAdded) { + target.onResourceReady(resource, null) + if (shouldStartTransition) photoActionsListener.onBringUp() + } } } else { // This waits for transition. If there's no transition then we should hit diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt index a3f880cb7..1e6f45625 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt @@ -100,6 +100,7 @@ abstract class ViewMediaFragment : Fragment() { override fun onDestroyView() { toolbarVisibilityDisposable?.invoke() + toolbarVisibilityDisposable = null super.onDestroyView() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt index 24dd51353..e40d79be4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt @@ -18,12 +18,10 @@ package com.keylesspalace.tusky.fragment import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.annotation.SuppressLint -import android.content.Context import android.graphics.drawable.Drawable import android.os.Bundle import android.os.Handler import android.os.Looper -import android.text.method.ScrollingMovementMethod import android.view.GestureDetector import android.view.Gravity import android.view.LayoutInflater @@ -33,7 +31,13 @@ import android.view.ViewGroup import android.widget.FrameLayout import android.widget.LinearLayout import androidx.annotation.OptIn -import androidx.core.view.GestureDetectorCompat +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat.Type.systemBars +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaItem @@ -43,25 +47,28 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.util.EventLogger import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView import com.bumptech.glide.Glide -import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.target.CustomViewTarget import com.bumptech.glide.request.transition.Transition import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.databinding.FragmentViewVideoBinding -import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Attachment -import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.getParcelableCompat +import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible +import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import javax.inject.Provider import kotlin.math.abs +@AndroidEntryPoint @OptIn(UnstableApi::class) -class ViewVideoFragment : ViewMediaFragment(), Injectable { +class ViewVideoFragment : ViewMediaFragment() { interface VideoActionsListener { fun onDismiss() } @@ -71,19 +78,23 @@ class ViewVideoFragment : ViewMediaFragment(), Injectable { private val binding by viewBinding(FragmentViewVideoBinding::bind) - private lateinit var videoActionsListener: VideoActionsListener - private lateinit var toolbar: View + private val videoActionsListener: VideoActionsListener + get() = requireActivity() as VideoActionsListener private val handler = Handler(Looper.getMainLooper()) private val hideToolbar = Runnable { // Hoist toolbar hiding to activity so it can track state across different fragments // This is explicitly stored as runnable so that we pass it to the handler later for cancellation mediaActivity.onPhotoTap() } - private lateinit var mediaActivity: ViewMediaActivity - private lateinit var mediaPlayerListener: Player.Listener - private var isAudio = false + private val mediaActivity: ViewMediaActivity + get() = requireActivity() as ViewMediaActivity + private val isAudio + get() = mediaAttachment.type == Attachment.Type.AUDIO - private lateinit var mediaAttachment: Attachment + private val mediaAttachment: Attachment by unsafeLazy { + arguments?.getParcelableCompat(ARG_ATTACHMENT) + ?: throw IllegalArgumentException("attachment has to be set") + } private var player: ExoPlayer? = null @@ -99,20 +110,12 @@ class ViewVideoFragment : ViewMediaFragment(), Injectable { /** Prevent the next play start from queueing a toolbar hide. */ private var suppressNextHideToolbar = false - override fun onAttach(context: Context) { - super.onAttach(context) - - videoActionsListener = context as VideoActionsListener - } - @SuppressLint("PrivateResource", "MissingInflatedId") override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - mediaActivity = activity as ViewMediaActivity - toolbar = mediaActivity.toolbar val rootView = inflater.inflate(R.layout.fragment_view_video, container, false) // Move the controls to the bottom of the screen, with enough bottom margin to clear the seekbar @@ -131,11 +134,16 @@ class ViewVideoFragment : ViewMediaFragment(), Injectable { @SuppressLint("ClickableViewAccessibility") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val attachment = arguments?.getParcelable(ARG_ATTACHMENT) - ?: throw IllegalArgumentException("attachment has to be set") - val url = attachment.url - isAudio = attachment.type == Attachment.Type.AUDIO + ViewCompat.setOnApplyWindowInsetsListener(binding.mediaDescriptionScrollView) { captionSheet, insets -> + val systemBarInsets = insets.getInsets(systemBars()) + captionSheet.updatePadding(bottom = systemBarInsets.bottom) + binding.videoView.updateLayoutParams { + bottomMargin = systemBarInsets.bottom + } + + insets.inset(0, 0, 0, systemBarInsets.bottom) + } /** * Handle single taps, flings, and dragging @@ -151,7 +159,7 @@ class ViewVideoFragment : ViewMediaFragment(), Injectable { ) /** Handle taps and flings */ - val simpleGestureDetector = GestureDetectorCompat( + val simpleGestureDetector = GestureDetector( requireContext(), object : GestureDetector.SimpleOnGestureListener() { override fun onDown(e: MotionEvent) = true @@ -209,7 +217,7 @@ class ViewVideoFragment : ViewMediaFragment(), Injectable { } } - mediaPlayerListener = object : Player.Listener { + val mediaPlayerListener = object : Player.Listener { @SuppressLint("ClickableViewAccessibility", "SyntheticAccessor") @OptIn(UnstableApi::class) override fun onPlaybackStateChanged(playbackState: Int) { @@ -271,25 +279,23 @@ class ViewVideoFragment : ViewMediaFragment(), Injectable { savedSeekPosition = savedInstanceState?.getLong(SEEK_POSITION) ?: 0 - mediaAttachment = attachment + val attachment = mediaAttachment + finalizeViewSetup(attachment.url, attachment.previewUrl, attachment.description) - finalizeViewSetup(url, attachment.previewUrl, attachment.description) - } + // Lifecycle callbacks + viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + initializePlayer(mediaPlayerListener) + binding.videoView.onResume() + } - override fun onStart() { - super.onStart() - - initializePlayer() - binding.videoView.onResume() - } - - override fun onStop() { - super.onStop() - - // This might be multi-window, so pause everything now. - binding.videoView.onPause() - releasePlayer() - handler.removeCallbacks(hideToolbar) + override fun onStop(owner: LifecycleOwner) { + // This might be multi-window, so pause everything now. + binding.videoView.onPause() + releasePlayer() + handler.removeCallbacks(hideToolbar) + } + }) } override fun onSaveInstanceState(outState: Bundle) { @@ -297,7 +303,7 @@ class ViewVideoFragment : ViewMediaFragment(), Injectable { outState.putLong(SEEK_POSITION, savedSeekPosition) } - private fun initializePlayer() { + private fun initializePlayer(mediaPlayerListener: Player.Listener) { player = playerProvider.get().apply { setAudioAttributes( AudioAttributes.Builder() @@ -320,22 +326,26 @@ class ViewVideoFragment : ViewMediaFragment(), Injectable { // Audio-only files might have a preview image. If they do, set it as the artwork if (isAudio) { mediaAttachment.previewUrl?.let { url -> - Glide.with(this).load(url).into(object : CustomTarget() { - @SuppressLint("SyntheticAccessor") - override fun onResourceReady( - resource: Drawable, - transition: Transition? - ) { - view ?: return - binding.videoView.defaultArtwork = resource - } + Glide.with(this) + .load(url) + .into( + object : CustomViewTarget(binding.videoView) { + override fun onLoadFailed(errorDrawable: Drawable?) { + // Don't do anything + } - @SuppressLint("SyntheticAccessor") - override fun onLoadCleared(placeholder: Drawable?) { - view ?: return - binding.videoView.defaultArtwork = null - } - }) + override fun onResourceCleared(placeholder: Drawable?) { + view.defaultArtwork = null + } + + override fun onResourceReady( + resource: Drawable, + transition: Transition? + ) { + view.defaultArtwork = resource + } + }.clearOnDetach() + ) } } } @@ -356,12 +366,11 @@ class ViewVideoFragment : ViewMediaFragment(), Injectable { description: String?, showingDescription: Boolean ) { - binding.mediaDescription.text = description - binding.mediaDescription.visible(showingDescription) - binding.mediaDescription.movementMethod = ScrollingMovementMethod() + binding.mediaDescriptionTextView.text = description + binding.mediaDescriptionScrollView.visible(showingDescription) // Ensure the description is visible over the video - binding.mediaDescription.elevation = binding.videoView.elevation + 1 + binding.mediaDescriptionScrollView.elevation = binding.videoView.elevation + 1 binding.videoView.transitionName = url @@ -378,7 +387,7 @@ class ViewVideoFragment : ViewMediaFragment(), Injectable { } override fun onToolbarVisibilityChange(visible: Boolean) { - if (!userVisibleHint) { + if (view == null) { return } @@ -386,16 +395,16 @@ class ViewVideoFragment : ViewMediaFragment(), Injectable { val alpha = if (isDescriptionVisible) 1.0f else 0.0f if (isDescriptionVisible) { // If to be visible, need to make visible immediately and animate alpha - binding.mediaDescription.alpha = 0.0f - binding.mediaDescription.visible(isDescriptionVisible) + binding.mediaDescriptionScrollView.alpha = 0.0f + binding.mediaDescriptionScrollView.visible(isDescriptionVisible) } - binding.mediaDescription.animate().alpha(alpha) + binding.mediaDescriptionScrollView.animate().alpha(alpha) .setListener(object : AnimatorListenerAdapter() { @SuppressLint("SyntheticAccessor") override fun onAnimationEnd(animation: Animator) { view ?: return - binding.mediaDescription.visible(isDescriptionVisible) + binding.mediaDescriptionScrollView.visible(isDescriptionVisible) animation.removeListener(this) } }) diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.kt index 654b50dd4..edb1e0475 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.kt +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.kt @@ -18,5 +18,5 @@ interface AccountActionListener { fun onViewAccount(id: String) fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) fun onBlock(block: Boolean, id: String, position: Int) - fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) + fun onRespondToFollowRequest(accept: Boolean, accountIdRequestingFollow: String, position: Int) } diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountSelectionListener.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountSelectionListener.kt index b86c55c76..272a16aa4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountSelectionListener.kt +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountSelectionListener.kt @@ -15,7 +15,7 @@ package com.keylesspalace.tusky.interfaces -import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.entity.AccountEntity interface AccountSelectionListener { fun onAccountSelected(account: AccountEntity) diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/FabFragment.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/FabFragment.kt deleted file mode 100644 index 1189dd3b3..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/FabFragment.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.interfaces - -interface FabFragment { - fun isFabVisible(): Boolean -} diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/HashtagActionListener.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/HashtagActionListener.kt index a223f268d..e617195c9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/HashtagActionListener.kt +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/HashtagActionListener.kt @@ -2,4 +2,6 @@ package com.keylesspalace.tusky.interfaces interface HashtagActionListener { fun unfollow(tagName: String, position: Int) + fun viewTag(tagName: String) + fun copyTagName(tagName: String) } diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/PermissionRequester.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/PermissionRequester.kt deleted file mode 100644 index d31bd1feb..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/PermissionRequester.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.keylesspalace.tusky.interfaces - -fun interface PermissionRequester { - fun onRequestPermissionsResult(permissions: Array, grantResults: IntArray) -} diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.kt index 75f6ed749..99503576f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.kt +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.kt @@ -12,60 +12,55 @@ * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ +package com.keylesspalace.tusky.interfaces -package com.keylesspalace.tusky.interfaces; +import android.view.View +import com.keylesspalace.tusky.entity.Status -import android.view.View; - -import java.util.List; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -public interface StatusActionListener extends LinkListener { - void onReply(int position); - void onReblog(final boolean reblog, final int position); - void onFavourite(final boolean favourite, final int position); - void onBookmark(final boolean bookmark, final int position); - void onMore(@NonNull View view, final int position); - void onViewMedia(int position, int attachmentIndex, @Nullable View view); - void onViewThread(int position); +interface StatusActionListener : LinkListener { + fun onReply(position: Int) + fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility = Status.Visibility.PUBLIC) + fun onFavourite(favourite: Boolean, position: Int) + fun onBookmark(bookmark: Boolean, position: Int) + fun onMore(view: View, position: Int) + fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) + fun onViewThread(position: Int) /** * Open reblog author for the status. * @param position At which position in the list status is located */ - void onOpenReblog(int position); - void onExpandedChange(boolean expanded, int position); - void onContentHiddenChange(boolean isShowing, int position); - void onLoadMore(int position); + fun onOpenReblog(position: Int) + fun onExpandedChange(expanded: Boolean, position: Int) + fun onContentHiddenChange(isShowing: Boolean, position: Int) + fun onLoadMore(position: Int) /** - * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long + * Called when the status [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 onContentCollapsedChange(boolean isCollapsed, int position); + fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) /** * called when the reblog count has been clicked * @param position The position of the status in the list. */ - default void onShowReblogs(int position) {} + fun onShowReblogs(position: Int) {} /** * called when the favourite count has been clicked * @param position The position of the status in the list. */ - default void onShowFavs(int position) {} + fun onShowFavs(position: Int) {} - void onVoteInPoll(int position, @NonNull List choices); + fun onVoteInPoll(position: Int, choices: List) - default void onShowEdits(int position) {} + fun onShowEdits(position: Int) {} - void clearWarningAction(int position); + fun clearWarningAction(position: Int) - void onUntranslate(int position); + fun onUntranslate(position: Int) } diff --git a/app/src/main/java/com/keylesspalace/tusky/json/GuardedAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/json/GuardedAdapter.kt index 11cb1f3af..579d58d12 100644 --- a/app/src/main/java/com/keylesspalace/tusky/json/GuardedAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/json/GuardedAdapter.kt @@ -17,8 +17,8 @@ package com.keylesspalace.tusky.json +import android.util.Log import com.squareup.moshi.JsonAdapter -import com.squareup.moshi.JsonDataException import com.squareup.moshi.JsonReader import com.squareup.moshi.JsonWriter import com.squareup.moshi.Moshi @@ -35,10 +35,12 @@ class GuardedAdapter private constructor( override fun fromJson(reader: JsonReader): T? { return try { - delegate.fromJson(reader) - } catch (e: JsonDataException) { - reader.skipValue() + reader.peekJson().use { delegate.fromJson(it) } + } catch (e: Exception) { + Log.w("GuardedAdapter", "failed to read json", e) null + } finally { + reader.skipValue() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/json/NotificationTypeAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/json/NotificationTypeAdapter.kt new file mode 100644 index 000000000..6d3a1263e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/json/NotificationTypeAdapter.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2025 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.json + +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.notificationTypeFromString +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter + +class NotificationTypeAdapter : JsonAdapter() { + + override fun fromJson(reader: JsonReader): Notification.Type { + return notificationTypeFromString(reader.nextString()) + } + + override fun toJson(writer: JsonWriter, value: Notification.Type?) { + writer.value(value?.name) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/ApiFactory.kt b/app/src/main/java/com/keylesspalace/tusky/network/ApiFactory.kt new file mode 100644 index 000000000..8bc70b5ff --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/network/ApiFactory.kt @@ -0,0 +1,55 @@ +package com.keylesspalace.tusky.network + +import com.keylesspalace.tusky.db.entity.AccountEntity +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.create + +/** + * Creates an instance of an Api that will only make requests as the provided account. + * @param account The account to make requests as. + * When null, request without additional DOMAIN_HEADER will fail. + * @param httpClient The OkHttpClient to make requests as + * @param retrofit The Retrofit instance to derive the api from + * @param scheme The scheme to use. Only used in tests. + * @param port The port to use. Only used in tests. + */ +inline fun apiForAccount( + account: AccountEntity?, + httpClient: OkHttpClient, + retrofit: Retrofit, + scheme: String = "https://", + port: Int? = null +): T { + return retrofit.newBuilder() + .apply { + if (account != null) { + baseUrl("$scheme${account.domain}${ if (port == null) "" else ":$port"}") + } + } + .callFactory { originalRequest -> + var request = originalRequest + + val domainHeader = originalRequest.header(MastodonApi.DOMAIN_HEADER) + if (domainHeader != null) { + request = originalRequest.newBuilder() + .url( + originalRequest.url.newBuilder().host(domainHeader).build() + ) + .removeHeader(MastodonApi.DOMAIN_HEADER) + .build() + } else if (account != null && request.url.host == account.domain) { + request = request.newBuilder() + .header("Authorization", "Bearer ${account.accessToken}") + .build() + } + + if (request.url.host == MastodonApi.PLACEHOLDER_DOMAIN) { + FailingCall(request) + } else { + httpClient.newCall(request) + } + } + .build() + .create() +} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/FailingCall.kt b/app/src/main/java/com/keylesspalace/tusky/network/FailingCall.kt new file mode 100644 index 000000000..c07489524 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/network/FailingCall.kt @@ -0,0 +1,65 @@ +/* Copyright 2024 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.network + +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.Timeout + +class FailingCall(private val request: Request) : Call { + + private var isExecuted: Boolean = false + + override fun cancel() { } + + override fun clone(): Call { + return FailingCall(request()) + } + + override fun enqueue(responseCallback: Callback) { + isExecuted = true + responseCallback.onResponse(this, failingResponse()) + } + + override fun execute(): Response { + isExecuted = true + return failingResponse() + } + + override fun isCanceled(): Boolean = false + + override fun isExecuted(): Boolean = isExecuted + + override fun request(): Request = request + + override fun timeout(): Timeout { + return Timeout.NONE + } + + private fun failingResponse(): Response { + return Response.Builder() + .request(request) + .code(400) + .message("Bad Request") + .protocol(Protocol.HTTP_1_1) + .body("".toResponseBody()) + .build() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt index 3763fa548..355212589 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt @@ -1,8 +1,13 @@ package com.keylesspalace.tusky.network +import android.util.Log +import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.getOrElse +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.parseAsMastodonHtml import java.util.Date import java.util.regex.Pattern @@ -11,17 +16,54 @@ import javax.inject.Inject /** * One-stop for status filtering logic using Mastodon's filters. * - * 1. You init with [initWithFilters], this compiles regex pattern. + * 1. You init with [init], this checks which filter version to use and compiles regex pattern if needed. * 2. You call [shouldFilterStatus] to figure out what to display when you load statuses. */ -class FilterModel @Inject constructor() { +class FilterModel @Inject constructor( + private val instanceInfoRepo: InstanceInfoRepository, + private val api: MastodonApi +) { private var pattern: Pattern? = null private var v1 = false - lateinit var kind: Filter.Kind + private lateinit var kind: Filter.Kind - fun initWithFilters(filters: List) { - v1 = true - this.pattern = makeFilter(filters) + /** + * @param kind the [Filter.Kind] that should be filtered + * @return true when filters v1 have been loaded successfully and the currently shown posts may need to be filtered + */ + suspend fun init(kind: Filter.Kind): Boolean { + this.kind = kind + + if (instanceInfoRepo.isFilterV2Supported()) { + // nothing to do - Instance supports V2 so posts are filtered by the server + return false + } + + api.getFilters().fold( + { + instanceInfoRepo.saveFilterV2Support(true) + return false + }, + { throwable -> + if (throwable.isHttpNotFound()) { + val filters = api.getFiltersV1().getOrElse { + Log.w(TAG, "Failed to fetch filters", it) + return false + } + + this.v1 = true + + val activeFilters = filters.filter { filter -> filter.context.contains(kind.kind) } + + this.pattern = makeFilter(activeFilters) + + return activeFilters.isNotEmpty() + } else { + Log.e(TAG, "Error getting filters", throwable) + return false + } + } + ) } fun shouldFilterStatus(status: Status): Filter.Action { @@ -62,7 +104,7 @@ class FilterModel @Inject constructor() { val phrase = filter.phrase val quotedPhrase = Pattern.quote(phrase) return if (filter.wholeWord && ALPHANUMERIC.matcher(phrase).matches()) { - String.format("(^|\\W)%s($|\\W)", quotedPhrase) + "(^|\\W)$quotedPhrase($|\\W)" } else { quotedPhrase } @@ -81,6 +123,7 @@ class FilterModel @Inject constructor() { } companion object { + private const val TAG = "FilterModel" private val ALPHANUMERIC = Pattern.compile("^\\w+$") } } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt b/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt deleted file mode 100644 index 6aabaa13d..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* Copyright 2022 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.network - -import android.util.Log -import com.keylesspalace.tusky.db.AccountManager -import java.io.IOException -import okhttp3.HttpUrl -import okhttp3.Interceptor -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.Protocol -import okhttp3.Request -import okhttp3.Response -import okhttp3.ResponseBody.Companion.toResponseBody - -class InstanceSwitchAuthInterceptor(private val accountManager: AccountManager) : Interceptor { - - @Throws(IOException::class) - override fun intercept(chain: Interceptor.Chain): Response { - val originalRequest: Request = chain.request() - - // only switch domains if the request comes from retrofit - return if (originalRequest.url.host == MastodonApi.PLACEHOLDER_DOMAIN) { - val builder: Request.Builder = originalRequest.newBuilder() - val instanceHeader = originalRequest.header(MastodonApi.DOMAIN_HEADER) - - if (instanceHeader != null) { - // use domain explicitly specified in custom header - builder.url(swapHost(originalRequest.url, instanceHeader)) - builder.removeHeader(MastodonApi.DOMAIN_HEADER) - } else { - val currentAccount = accountManager.activeAccount - - if (currentAccount != null) { - val accessToken = currentAccount.accessToken - if (accessToken.isNotEmpty()) { - // use domain of current account - builder.url(swapHost(originalRequest.url, currentAccount.domain)) - .header("Authorization", "Bearer %s".format(accessToken)) - } - } - } - - val newRequest: Request = builder.build() - - if (MastodonApi.PLACEHOLDER_DOMAIN == newRequest.url.host) { - Log.w( - "ISAInterceptor", - "no user logged in or no domain header specified - can't make request to " + newRequest.url - ) - return Response.Builder() - .code(400) - .message("Bad Request") - .protocol(Protocol.HTTP_2) - .body("".toResponseBody("text/plain".toMediaType())) - .request(chain.request()) - .build() - } - - chain.proceed(newRequest) - } else { - chain.proceed(originalRequest) - } - } - - companion object { - private fun swapHost(url: HttpUrl, host: String): HttpUrl { - return url.newBuilder().host(host).build() - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 5498f5382..40617328b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.network import at.connyduck.calladapter.networkresult.NetworkResult +import com.keylesspalace.tusky.components.filters.FilterExpiration import com.keylesspalace.tusky.entity.AccessToken import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Announcement @@ -35,10 +36,13 @@ import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.entity.MediaUploadResult import com.keylesspalace.tusky.entity.NewStatus import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.NotificationPolicy +import com.keylesspalace.tusky.entity.NotificationRequest import com.keylesspalace.tusky.entity.NotificationSubscribeResult import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.ScheduledStatus +import com.keylesspalace.tusky.entity.ScheduledStatusReply import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.StatusContext @@ -49,7 +53,6 @@ import com.keylesspalace.tusky.entity.Translation import com.keylesspalace.tusky.entity.TrendingTag import okhttp3.MultipartBody import okhttp3.RequestBody -import retrofit2.Call import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE @@ -64,6 +67,7 @@ import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Part +import retrofit2.http.PartMap import retrofit2.http.Path import retrofit2.http.Query @@ -138,15 +142,20 @@ interface MastodonApi { ): Response> @GET("api/v1/notifications") + @Throws(Exception::class) suspend fun notifications( /** Return results older than this ID */ - @Query("max_id") maxId: String?, + @Query("max_id") maxId: String? = null, /** Return results newer than this ID */ - @Query("since_id") sinceId: String?, + @Query("since_id") sinceId: String? = null, + /** Return results immediately newer than this ID */ + @Query("min_id") minId: String? = null, /** Maximum number of results to return. Defaults to 15, max is 30 */ - @Query("limit") limit: Int?, + @Query("limit") limit: Int? = null, /** Types to excludes from the results */ - @Query("exclude_types[]") excludes: Set? + @Query("exclude_types[]") excludes: Set? = null, + /** Return only notifications received from the specified account. */ + @Query("account_id") accountId: String? = null ): Response> /** Fetch a single notification */ @@ -205,7 +214,7 @@ interface MastodonApi { @Header(DOMAIN_HEADER) domain: String, @Header("Idempotency-Key") idempotencyKey: String, @Body status: NewStatus - ): NetworkResult + ): NetworkResult @GET("api/v1/statuses/{id}") suspend fun status(@Path("id") statusId: String): NetworkResult @@ -243,8 +252,9 @@ interface MastodonApi { @DELETE("api/v1/statuses/{id}") suspend fun deleteStatus(@Path("id") statusId: String): NetworkResult + @FormUrlEncoded @POST("api/v1/statuses/{id}/reblog") - suspend fun reblogStatus(@Path("id") statusId: String): NetworkResult + suspend fun reblogStatus(@Path("id") statusId: String, @Field("visibility") visibility: String?): NetworkResult @POST("api/v1/statuses/{id}/unreblog") suspend fun unreblogStatus(@Path("id") statusId: String): NetworkResult @@ -292,11 +302,11 @@ interface MastodonApi { @FormUrlEncoded @PATCH("api/v1/accounts/update_credentials") - fun accountUpdateSource( + suspend fun accountUpdateSource( @Field("source[privacy]") privacy: String?, @Field("source[sensitive]") sensitive: Boolean?, @Field("source[language]") language: String? - ): Call + ): NetworkResult @Multipart @PATCH("api/v1/accounts/update_credentials") @@ -306,14 +316,7 @@ interface MastodonApi { @Part(value = "locked") locked: RequestBody?, @Part avatar: MultipartBody.Part?, @Part header: MultipartBody.Part?, - @Part(value = "fields_attributes[0][name]") fieldName0: RequestBody?, - @Part(value = "fields_attributes[0][value]") fieldValue0: RequestBody?, - @Part(value = "fields_attributes[1][name]") fieldName1: RequestBody?, - @Part(value = "fields_attributes[1][value]") fieldValue1: RequestBody?, - @Part(value = "fields_attributes[2][name]") fieldName2: RequestBody?, - @Part(value = "fields_attributes[2][value]") fieldValue2: RequestBody?, - @Part(value = "fields_attributes[3][name]") fieldName3: RequestBody?, - @Part(value = "fields_attributes[3][value]") fieldValue3: RequestBody? + @PartMap fields: Map ): NetworkResult @GET("api/v1/accounts/search") @@ -537,7 +540,7 @@ interface MastodonApi { @Field("context[]") context: List, @Field("irreversible") irreversible: Boolean?, @Field("whole_word") wholeWord: Boolean?, - @Field("expires_in") expiresInSeconds: Int? + @Field("expires_in") expiresIn: FilterExpiration? ): NetworkResult @FormUrlEncoded @@ -548,7 +551,7 @@ interface MastodonApi { @Field("context[]") context: List, @Field("irreversible") irreversible: Boolean?, @Field("whole_word") wholeWord: Boolean?, - @Field("expires_in") expiresInSeconds: Int? + @Field("expires_in") expiresIn: FilterExpiration? ): NetworkResult @DELETE("api/v1/filters/{id}") @@ -560,7 +563,7 @@ interface MastodonApi { @Field("title") title: String, @Field("context[]") context: List, @Field("filter_action") filterAction: String, - @Field("expires_in") expiresInSeconds: Int? + @Field("expires_in") expiresIn: FilterExpiration? ): NetworkResult @FormUrlEncoded @@ -570,7 +573,7 @@ interface MastodonApi { @Field("title") title: String? = null, @Field("context[]") context: List? = null, @Field("filter_action") filterAction: String? = null, - @Field("expires_in") expiresInSeconds: Int? = null + @Field("expires_in") expires: FilterExpiration? = null ): NetworkResult @DELETE("api/v2/filters/{id}") @@ -605,9 +608,7 @@ interface MastodonApi { ): NetworkResult @GET("api/v1/announcements") - suspend fun listAnnouncements( - @Query("with_dismissed") withDismissed: Boolean = true - ): NetworkResult> + suspend fun announcements(): NetworkResult> @POST("api/v1/announcements/{id}/dismiss") suspend fun dismissAnnouncement(@Path("id") announcementId: String): NetworkResult @@ -660,6 +661,12 @@ interface MastodonApi { @Field("comment") note: String ): NetworkResult + @GET("api/v1/push/subscription") + suspend fun pushNotificationSubscription( + @Header("Authorization") auth: String, + @Header(DOMAIN_HEADER) domain: String + ): NetworkResult + @FormUrlEncoded @POST("api/v1/push/subscription") suspend fun subscribePushNotifications( @@ -720,4 +727,31 @@ interface MastodonApi { @Path("id") statusId: String, @Field("lang") targetLanguage: String? ): NetworkResult + + @GET("api/v2/notifications/policy") + suspend fun notificationPolicy(): NetworkResult + + @FormUrlEncoded + @PATCH("api/v2/notifications/policy") + suspend fun updateNotificationPolicy( + @Field("for_not_following") forNotFollowing: String?, + @Field("for_not_followers") forNotFollowers: String?, + @Field("for_new_accounts") forNewAccounts: String?, + @Field("for_private_mentions") forPrivateMentions: String?, + @Field("for_limited_accounts") forLimitedAccounts: String? + ): NetworkResult + + @GET("api/v1/notifications/requests") + suspend fun getNotificationRequests( + @Query("max_id") maxId: String? = null, + @Query("min_id") minId: String? = null, + @Query("since_id") sinceId: String? = null, + @Query("limit") limit: Int? = null + ): Response> + + @POST("api/v1/notifications/requests/{id}/accept") + suspend fun acceptNotificationRequest(@Path("id") notificationId: String): NetworkResult + + @POST("api/v1/notifications/requests/{id}/dismiss") + suspend fun dismissNotificationRequest(@Path("id") notificationId: String): NetworkResult } diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt index a2656d0f1..07f63fbd9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt @@ -20,19 +20,16 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.os.Build -import com.keylesspalace.tusky.components.notifications.canEnablePushNotifications -import com.keylesspalace.tusky.components.notifications.isUnifiedPushNotificationEnabledForAccount -import com.keylesspalace.tusky.components.notifications.updateUnifiedPushSubscription +import com.keylesspalace.tusky.components.systemnotifications.NotificationService import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.network.MastodonApi -import dagger.android.AndroidInjection +import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.launch -@DelicateCoroutinesApi +@AndroidEntryPoint class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() { @Inject lateinit var mastodonApi: MastodonApi @@ -40,18 +37,20 @@ class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() { @Inject lateinit var accountManager: AccountManager + @Inject + lateinit var notificationService: NotificationService + @Inject @ApplicationScope lateinit var externalScope: CoroutineScope override fun onReceive(context: Context, intent: Intent) { - AndroidInjection.inject(this, context) if (Build.VERSION.SDK_INT < 28) return - if (!canEnablePushNotifications(context, accountManager)) return + if (!notificationService.arePushNotificationsAvailable()) return val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val gid = when (intent.action) { + val accountIdentifier = when (intent.action) { NotificationManager.ACTION_NOTIFICATION_CHANNEL_BLOCK_STATE_CHANGED -> { val channelId = intent.getStringExtra(NotificationManager.EXTRA_NOTIFICATION_CHANNEL_ID) nm.getNotificationChannel(channelId).group @@ -62,16 +61,10 @@ class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() { else -> null } ?: return - accountManager.getAccountByIdentifier(gid)?.let { account -> - if (isUnifiedPushNotificationEnabledForAccount(account)) { - // Update UnifiedPush notification subscription + accountManager.getAccountByIdentifier(accountIdentifier)?.let { account -> + if (account.isPushNotificationsEnabled()) { externalScope.launch { - updateUnifiedPushSubscription( - context, - mastodonApi, - accountManager, - account - ) + notificationService.updatePushSubscription(account) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index 23eeadb04..3d9529636 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -1,4 +1,4 @@ -/* Copyright 2018 Jeremiasz Nelz +/* Copyright 2018 Tusky contributors * * This file is a part of Tusky. * @@ -24,17 +24,18 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.RemoteInput import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.notifications.NotificationHelper +import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData +import com.keylesspalace.tusky.components.systemnotifications.NotificationService import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.service.SendStatusService import com.keylesspalace.tusky.service.StatusToSend +import com.keylesspalace.tusky.util.getSerializableExtraCompat import com.keylesspalace.tusky.util.randomAlphanumericString -import dagger.android.AndroidInjection +import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -private const val TAG = "SendStatusBR" - +@AndroidEntryPoint class SendStatusBroadcastReceiver : BroadcastReceiver() { @Inject @@ -42,23 +43,20 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { @SuppressLint("MissingPermission") override fun onReceive(context: Context, intent: Intent) { - AndroidInjection.inject(this, context) - - if (intent.action == NotificationHelper.REPLY_ACTION) { - val serverNotificationId = intent.getStringExtra(NotificationHelper.KEY_SERVER_NOTIFICATION_ID) - val senderId = intent.getLongExtra(NotificationHelper.KEY_SENDER_ACCOUNT_ID, -1) + if (intent.action == NotificationService.REPLY_ACTION) { + val serverNotificationId = intent.getStringExtra(NotificationService.KEY_SERVER_NOTIFICATION_ID) + val senderId = intent.getLongExtra(NotificationService.KEY_SENDER_ACCOUNT_ID, -1) val senderIdentifier = intent.getStringExtra( - NotificationHelper.KEY_SENDER_ACCOUNT_IDENTIFIER - ) + NotificationService.KEY_SENDER_ACCOUNT_IDENTIFIER + )!! val senderFullName = intent.getStringExtra( - NotificationHelper.KEY_SENDER_ACCOUNT_FULL_NAME + NotificationService.KEY_SENDER_ACCOUNT_FULL_NAME ) - val citedStatusId = intent.getStringExtra(NotificationHelper.KEY_CITED_STATUS_ID) - val visibility = intent.getSerializableExtra( - NotificationHelper.KEY_VISIBILITY - ) as Status.Visibility - val spoiler = intent.getStringExtra(NotificationHelper.KEY_SPOILER).orEmpty() - val mentions = intent.getStringArrayExtra(NotificationHelper.KEY_MENTIONS).orEmpty() + val citedStatusId = intent.getStringExtra(NotificationService.KEY_CITED_STATUS_ID) + val visibility = + intent.getSerializableExtraCompat(NotificationService.KEY_VISIBILITY)!! + val spoiler = intent.getStringExtra(NotificationService.KEY_SPOILER).orEmpty() + val mentions = intent.getStringArrayExtra(NotificationService.KEY_MENTIONS).orEmpty() val account = accountManager.getAccountById(senderId) @@ -71,7 +69,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { val notification = NotificationCompat.Builder( context, - NotificationHelper.CHANNEL_MENTION + senderIdentifier + NotificationChannelData.MENTION.getChannelId(senderIdentifier) ) .setSmallIcon(R.drawable.ic_notify) .setColor(context.getColor(R.color.chinwag_green)) @@ -94,7 +92,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { StatusToSend( text = text, warningText = spoiler, - visibility = visibility.serverString, + visibility = visibility.stringValue, sensitive = false, media = emptyList(), scheduledAt = null, @@ -116,7 +114,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { // Notifications with remote input active can't be cancelled, so let's replace it with another one that will dismiss automatically val notification = NotificationCompat.Builder( context, - NotificationHelper.CHANNEL_MENTION + senderIdentifier + NotificationChannelData.MENTION.getChannelId(senderIdentifier) ) .setSmallIcon(R.drawable.ic_notify) .setColor(context.getColor(R.color.notification_color)) @@ -139,6 +137,10 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { private fun getReplyMessage(intent: Intent): CharSequence { val remoteInput = RemoteInput.getResultsFromIntent(intent) - return remoteInput?.getCharSequence(NotificationHelper.KEY_REPLY, "") ?: "" + return remoteInput?.getCharSequence(NotificationService.KEY_REPLY, "") ?: "" + } + + companion object { + const val TAG = "SendStatusBroadcastReceiver" } } diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt index 40fe48438..bb8feb043 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt @@ -16,29 +16,19 @@ package com.keylesspalace.tusky.receiver import android.content.Context -import android.content.Intent import android.util.Log -import androidx.work.OneTimeWorkRequest -import androidx.work.WorkManager -import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint -import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint +import com.keylesspalace.tusky.components.systemnotifications.NotificationService import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.worker.NotificationWorker -import dagger.android.AndroidInjection +import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.launch import org.unifiedpush.android.connector.MessagingReceiver -@DelicateCoroutinesApi +@AndroidEntryPoint class UnifiedPushBroadcastReceiver : MessagingReceiver() { - companion object { - const val TAG = "UnifiedPush" - } - @Inject lateinit var accountManager: AccountManager @@ -46,40 +36,39 @@ class UnifiedPushBroadcastReceiver : MessagingReceiver() { lateinit var mastodonApi: MastodonApi @Inject - @ApplicationScope - lateinit var externalScope: CoroutineScope + lateinit var notificationService: NotificationService - override fun onReceive(context: Context, intent: Intent) { - super.onReceive(context, intent) - AndroidInjection.inject(this, context) - } + @Inject + @ApplicationScope + lateinit var applicationScope: CoroutineScope override fun onMessage(context: Context, message: ByteArray, instance: String) { - AndroidInjection.inject(this, context) - Log.d(TAG, "New message received for account $instance") - val workManager = WorkManager.getInstance(context) - val request = OneTimeWorkRequest.from(NotificationWorker::class.java) - workManager.enqueue(request) + Log.d(TAG, "New message received for account $instance: #${message.size}") + val account = accountManager.getAccountById(instance.toLong()) + account?.let { + notificationService.fetchNotificationsOnPushMessage(it) + } } override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { - AndroidInjection.inject(this, context) Log.d(TAG, "Endpoint available for account $instance: $endpoint") accountManager.getAccountById(instance.toLong())?.let { - externalScope.launch { - registerUnifiedPushEndpoint(context, mastodonApi, accountManager, it, endpoint) - } + applicationScope.launch { notificationService.registerPushEndpoint(it, endpoint) } } } override fun onRegistrationFailed(context: Context, instance: String) = Unit override fun onUnregistered(context: Context, instance: String) { - AndroidInjection.inject(this, context) Log.d(TAG, "Endpoint unregistered for account $instance") accountManager.getAccountById(instance.toLong())?.let { // It's fine if the account does not exist anymore -- that means it has been logged out - externalScope.launch { unregisterUnifiedPushEndpoint(mastodonApi, accountManager, it) } + // TODO its not: this is the Mastodon side and should be done (unregistered) + applicationScope.launch { notificationService.unregisterPushEndpoint(it) } } } + + companion object { + const val TAG = "UnifiedPushBroadcastReceiver" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt index 84f5c5981..4d3d96cca 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt @@ -31,7 +31,6 @@ import android.util.Log import androidx.annotation.StringRes import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat -import androidx.core.content.IntentCompat import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.R @@ -42,18 +41,17 @@ import com.keylesspalace.tusky.appstore.StatusScheduledEvent import com.keylesspalace.tusky.components.compose.MediaUploader import com.keylesspalace.tusky.components.compose.UploadEvent import com.keylesspalace.tusky.components.drafts.DraftHelper -import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.MediaAttribute import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewStatus -import com.keylesspalace.tusky.entity.ScheduledStatus +import com.keylesspalace.tusky.entity.ScheduledStatusReply import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.getParcelableExtraCompat import com.keylesspalace.tusky.util.unsafeLazy -import dagger.android.AndroidInjection +import dagger.hilt.android.AndroidEntryPoint import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -66,7 +64,8 @@ import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import retrofit2.HttpException -class SendStatusService : Service(), Injectable { +@AndroidEntryPoint +class SendStatusService : Service() { @Inject lateinit var mastodonApi: MastodonApi @@ -93,16 +92,11 @@ class SendStatusService : Service(), Injectable { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } - override fun onCreate() { - AndroidInjection.inject(this) - super.onCreate() - } - override fun onBind(intent: Intent): IBinder? = null override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { if (intent.hasExtra(KEY_STATUS)) { - val statusToSend: StatusToSend = IntentCompat.getParcelableExtra(intent, KEY_STATUS, StatusToSend::class.java) + val statusToSend: StatusToSend = intent.getParcelableExtraCompat(KEY_STATUS) ?: throw IllegalStateException("SendStatusService started without $KEY_STATUS extra") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -247,11 +241,11 @@ class SendStatusService : Service(), Injectable { scheduledAt = statusToSend.scheduledAt, poll = statusToSend.poll, language = statusToSend.language, - mediaAttributes = media.map { media -> + mediaAttributes = media.map { mediaItem -> MediaAttribute( - id = media.id!!, - description = media.description, - focus = media.focus?.toMastodonApiString(), + id = mediaItem.id!!, + description = mediaItem.description, + focus = mediaItem.focus?.toMastodonApiString(), thumbnail = null ) } @@ -295,7 +289,7 @@ class SendStatusService : Service(), Injectable { mediaUploader.cancelUploadScope(*statusToSend.media.map { it.localId }.toIntArray()) if (scheduled) { - eventHub.dispatch(StatusScheduledEvent(sentStatus as ScheduledStatus)) + eventHub.dispatch(StatusScheduledEvent((sentStatus as ScheduledStatusReply).id)) } else if (!isNew) { eventHub.dispatch(StatusChangedEvent(sentStatus as Status)) } else { @@ -398,7 +392,7 @@ class SendStatusService : Service(), Injectable { content = status.text, contentWarning = status.warningText, sensitive = status.sensitive, - visibility = Status.Visibility.byString(status.visibility), + visibility = Status.Visibility.fromStringValue(status.visibility), mediaUris = status.media.map { it.uri }, mediaDescriptions = status.media.map { it.description }, mediaFocus = status.media.map { it.focus }, @@ -418,7 +412,7 @@ class SendStatusService : Service(), Injectable { this, statusId, intent, - NotificationHelper.pendingIntentFlags(false) + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) } @@ -434,7 +428,7 @@ class SendStatusService : Service(), Injectable { this, statusId, intent, - NotificationHelper.pendingIntentFlags(false) + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) return NotificationCompat.Builder(this@SendStatusService, CHANNEL_ID) diff --git a/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt b/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt index d9d08ff70..0269b66c1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt @@ -17,9 +17,10 @@ package com.keylesspalace.tusky.service import android.content.Context import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -class ServiceClient @Inject constructor(private val context: Context) { +class ServiceClient @Inject constructor(@ApplicationContext private val context: Context) { fun sendToot(tootToSend: StatusToSend) { val intent = SendStatusService.sendStatusIntent(context, tootToSend) ContextCompat.startForegroundService(context, intent) diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt b/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt index 479b43310..bac5efbf3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt @@ -3,8 +3,8 @@ package com.keylesspalace.tusky.settings import androidx.preference.PreferenceDataStore import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent -import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.di.ApplicationScope import javax.inject.Inject import kotlinx.coroutines.CoroutineScope @@ -30,18 +30,18 @@ class AccountPreferenceDataStore @Inject constructor( } override fun putBoolean(key: String, value: Boolean) { - when (key) { - PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> account.alwaysShowSensitiveMedia = value - PrefKeys.ALWAYS_OPEN_SPOILER -> account.alwaysOpenSpoiler = value - PrefKeys.MEDIA_PREVIEW_ENABLED -> account.mediaPreviewEnabled = value - PrefKeys.TAB_FILTER_HOME_BOOSTS -> account.isShowHomeBoosts = value - PrefKeys.TAB_FILTER_HOME_REPLIES -> account.isShowHomeReplies = value - PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> account.isShowHomeSelfBoosts = value - } - - accountManager.saveAccount(account) - externalScope.launch { + accountManager.updateAccount(account) { + when (key) { + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> copy(alwaysShowSensitiveMedia = value) + PrefKeys.ALWAYS_OPEN_SPOILER -> copy(alwaysOpenSpoiler = value) + PrefKeys.MEDIA_PREVIEW_ENABLED -> copy(mediaPreviewEnabled = value) + PrefKeys.TAB_FILTER_HOME_BOOSTS -> copy(isShowHomeBoosts = value) + PrefKeys.TAB_FILTER_HOME_REPLIES -> copy(isShowHomeReplies = value) + PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> copy(isShowHomeSelfBoosts = value) + else -> this + } + } eventHub.dispatch(PreferenceChangedEvent(key)) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/DefaultReplyVisibility.kt b/app/src/main/java/com/keylesspalace/tusky/settings/DefaultReplyVisibility.kt new file mode 100644 index 000000000..9456b6c0d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/settings/DefaultReplyVisibility.kt @@ -0,0 +1,51 @@ +package com.keylesspalace.tusky.settings + +import com.keylesspalace.tusky.entity.Status + +enum class DefaultReplyVisibility(val int: Int) { + MATCH_DEFAULT_POST_VISIBILITY(0), + PUBLIC(1), + UNLISTED(2), + PRIVATE(3), + DIRECT(4); + + val stringValue: String + get() = when (this) { + MATCH_DEFAULT_POST_VISIBILITY -> "match_default_post_visibility" + PUBLIC -> "public" + UNLISTED -> "unlisted" + PRIVATE -> "private" + DIRECT -> "direct" + } + + fun toVisibilityOr(default: Status.Visibility): Status.Visibility { + return when (this) { + PUBLIC -> Status.Visibility.PUBLIC + UNLISTED -> Status.Visibility.UNLISTED + PRIVATE -> Status.Visibility.PRIVATE + DIRECT -> Status.Visibility.DIRECT + else -> default + } + } + + companion object { + fun fromInt(int: Int): DefaultReplyVisibility { + return when (int) { + 4 -> DIRECT + 3 -> PRIVATE + 2 -> UNLISTED + 1 -> PUBLIC + else -> MATCH_DEFAULT_POST_VISIBILITY + } + } + fun fromStringValue(s: String): DefaultReplyVisibility { + return when (s) { + "public" -> PUBLIC + "unlisted" -> UNLISTED + "private" -> PRIVATE + "direct" -> DIRECT + else -> MATCH_DEFAULT_POST_VISIBILITY + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt index 6005c9600..ccac9347c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -45,7 +45,7 @@ enum class AppTheme(val value: String) { * * - Adding a new preference that does not change the interpretation of an existing preference */ -const val SCHEMA_VERSION = 2023112001 +const val SCHEMA_VERSION = 2025022001 /** The schema version for fresh installs */ const val NEW_INSTALL_SCHEMA_VERSION = 0 @@ -55,22 +55,24 @@ object PrefKeys { // each preference a key for it to work. const val SCHEMA_VERSION: String = "schema_version" + const val LAST_USED_PUSH_PROVDER = "lastUsedPushProvider" + const val APP_THEME = "appTheme" - const val FAB_HIDE = "fabHide" const val LANGUAGE = "language" const val STATUS_TEXT_SIZE = "statusTextSize" const val READING_ORDER = "readingOrder" const val MAIN_NAV_POSITION = "mainNavPosition" const val HIDE_TOP_TOOLBAR = "hideTopToolbar" + const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter" const val ABSOLUTE_TIME_VIEW = "absoluteTimeView" const val SHOW_BOT_OVERLAY = "showBotOverlay" const val ANIMATE_GIF_AVATARS = "animateGifAvatars" const val USE_BLURHASH = "useBlurhash" const val SHOW_SELF_USERNAME = "showSelfUsername" const val SHOW_CARDS_IN_TIMELINES = "showCardsInTimelines" - const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter" const val CONFIRM_REBLOGS = "confirmReblogs" const val CONFIRM_FAVOURITES = "confirmFavourites" + const val CONFIRM_FOLLOWS = "confirmFollows" const val ENABLE_SWIPE_FOR_TABS = "enableSwipeForTabs" const val ANIMATE_CUSTOM_EMOJIS = "animateCustomEmojis" const val SHOW_STATS_INLINE = "showStatsInline" @@ -86,6 +88,7 @@ object PrefKeys { const val DEFAULT_POST_PRIVACY = "defaultPostPrivacy" const val DEFAULT_POST_LANGUAGE = "defaultPostLanguage" + const val DEFAULT_REPLY_PRIVACY = "defaultReplyPrivacy" const val DEFAULT_MEDIA_SENSITIVITY = "defaultMediaSensitivity" const val MEDIA_PREVIEW_ENABLED = "mediaPreviewEnabled" const val ALWAYS_SHOW_SENSITIVE_MEDIA = "alwaysShowSensitiveMedia" @@ -95,15 +98,6 @@ object PrefKeys { const val NOTIFICATION_ALERT_LIGHT = "notificationAlertLight" const val NOTIFICATION_ALERT_VIBRATE = "notificationAlertVibrate" const val NOTIFICATION_ALERT_SOUND = "notificationAlertSound" - const val NOTIFICATION_FILTER_POLLS = "notificationFilterPolls" - const val NOTIFICATION_FILTER_FAVS = "notificationFilterFavourites" - const val NOTIFICATION_FILTER_REBLOGS = "notificationFilterReblogs" - const val NOTIFICATION_FILTER_FOLLOW_REQUESTS = "notificationFilterFollowRequests" - const val NOTIFICATIONS_FILTER_FOLLOWS = "notificationFilterFollows" - const val NOTIFICATION_FILTER_SUBSCRIPTIONS = "notificationFilterSubscriptions" - const val NOTIFICATION_FILTER_SIGN_UPS = "notificationFilterSignUps" - const val NOTIFICATION_FILTER_UPDATES = "notificationFilterUpdates" - const val NOTIFICATION_FILTER_REPORTS = "notificationFilterReports" const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies_v2" // This was changed once to reset an unintentionally set default. const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts" @@ -112,8 +106,7 @@ object PrefKeys { /** UI text scaling factor, stored as float, 100 = 100% = no scaling */ const val UI_TEXT_SCALE_RATIO = "uiTextScaleRatio" - /** Keys that are no longer used (e.g., the preference has been removed */ object Deprecated { - const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter" + const val FAB_HIDE = "fabHide" } } diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt index c1b57427d..08de72791 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt @@ -12,7 +12,7 @@ import androidx.preference.Preference import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceScreen -import androidx.preference.SwitchPreference +import androidx.preference.SwitchPreferenceCompat import com.keylesspalace.tusky.view.SliderPreference import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference @@ -56,9 +56,9 @@ inline fun PreferenceParent.sliderPreference( } inline fun PreferenceParent.switchPreference( - builder: SwitchPreference.() -> Unit -): SwitchPreference { - val pref = SwitchPreference(context) + builder: SwitchPreferenceCompat.() -> Unit +): SwitchPreferenceCompat { + val pref = SwitchPreferenceCompat(context) builder(pref) addPref(pref) return pref diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/DeveloperToolsUseCase.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/DeveloperToolsUseCase.kt index 8724718a8..f2d0d3645 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/DeveloperToolsUseCase.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/DeveloperToolsUseCase.kt @@ -3,7 +3,7 @@ package com.keylesspalace.tusky.usecase import android.util.Log import androidx.room.withTransaction import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.db.TimelineDao +import com.keylesspalace.tusky.db.dao.TimelineDao import javax.inject.Inject /** @@ -25,18 +25,18 @@ class DeveloperToolsUseCase @Inject constructor( */ suspend fun createLoadMoreGap(accountId: Long) { db.withTransaction { - val ids = timelineDao.getMostRecentNStatusIds(accountId, 10) + val ids = timelineDao.getMostRecentNHomeTimelineIds(accountId, 10) val maxId = ids[2] val minId = ids[8] val placeHolderId = ids[9] Log.d( - "TAG", + TAG, "createLoadMoreGap: creating gap between $minId .. $maxId (new placeholder: $placeHolderId" ) timelineDao.deleteRange(accountId, minId, maxId) - timelineDao.convertStatustoPlaceholder(placeHolderId) + timelineDao.convertHomeTimelineItemToPlaceholder(placeHolderId) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt index 1bcab4179..e8e0db1e3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt @@ -1,67 +1,52 @@ package com.keylesspalace.tusky.usecase -import android.content.Context import com.keylesspalace.tusky.components.drafts.DraftHelper -import com.keylesspalace.tusky.components.notifications.NotificationHelper -import com.keylesspalace.tusky.components.notifications.disableUnifiedPushNotificationsForAccount +import com.keylesspalace.tusky.components.systemnotifications.NotificationService import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.DatabaseCleaner +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.ShareShortcutHelper import javax.inject.Inject class LogoutUsecase @Inject constructor( - private val context: Context, private val api: MastodonApi, - private val db: AppDatabase, + private val databaseCleaner: DatabaseCleaner, private val accountManager: AccountManager, private val draftHelper: DraftHelper, - private val shareShortcutHelper: ShareShortcutHelper + private val shareShortcutHelper: ShareShortcutHelper, + private val notificationService: NotificationService, ) { /** * Logs the current account out and clears all caches associated with it * @return true if the user is logged in with other accounts, false if it was the only one */ - suspend fun logout(): Boolean { - accountManager.activeAccount?.let { activeAccount -> - - // invalidate the oauth token, if we have the client id & secret - // (could be missing if user logged in with a previous version of Tusky) - val clientId = activeAccount.clientId - val clientSecret = activeAccount.clientSecret - if (clientId != null && clientSecret != null) { - api.revokeOAuthToken( - clientId = clientId, - clientSecret = clientSecret, - token = activeAccount.accessToken - ) - } - - // disable push notifications - disableUnifiedPushNotificationsForAccount(context, activeAccount) - - // disable pull notifications - if (!NotificationHelper.areNotificationsEnabled(context, accountManager)) { - NotificationHelper.disablePullNotifications(context) - } - - // clear notification channels - NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, context) - - // remove account from local AccountManager - val otherAccountAvailable = accountManager.logActiveAccountOut() != null - - // clear the database - this could trigger network calls so do it last when all tokens are gone - db.timelineDao().removeAll(activeAccount.id) - db.conversationDao().deleteForAccount(activeAccount.id) - draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id) - - // remove shortcut associated with the account - shareShortcutHelper.removeShortcut(activeAccount) - - return otherAccountAvailable + suspend fun logout(account: AccountEntity): Boolean { + // invalidate the oauth token, if we have the client id & secret + // (could be missing if user logged in with a previous version of Tusky) + val clientId = account.clientId + val clientSecret = account.clientSecret + if (clientId != null && clientSecret != null) { + api.revokeOAuthToken( + clientId = clientId, + clientSecret = clientSecret, + token = account.accessToken + ) } - return false + + notificationService.disableNotificationsForAccount(account) + + // remove account from local AccountManager + val otherAccountAvailable = accountManager.remove(account) != null + + // clear the database - this could trigger network calls so do it last when all tokens are gone + databaseCleaner.cleanupEverything(account.id) + draftHelper.deleteAllDraftsAndAttachmentsForAccount(account.id) + + // remove shortcut associated with the account + shareShortcutHelper.removeShortcut(account) + + return otherAccountAvailable } } diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/NotificationPolicyUsecase.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/NotificationPolicyUsecase.kt new file mode 100644 index 000000000..06036fcc3 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/NotificationPolicyUsecase.kt @@ -0,0 +1,94 @@ +package com.keylesspalace.tusky.usecase + +import at.connyduck.calladapter.networkresult.NetworkResult +import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.onSuccess +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity +import com.keylesspalace.tusky.entity.NotificationPolicy +import com.keylesspalace.tusky.network.MastodonApi +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import retrofit2.HttpException + +class NotificationPolicyUsecase @Inject constructor( + private val api: MastodonApi, + private val db: AppDatabase, + accountManager: AccountManager +) { + + private val accountId = accountManager.activeAccount!!.id + + private val _state: MutableStateFlow = MutableStateFlow(NotificationPolicyState.Loading) + val state: StateFlow = _state.asStateFlow() + + val info: Flow = db.notificationPolicyDao().notificationPolicyForAccount(accountId) + + suspend fun getNotificationPolicy() { + _state.value.let { state -> + if (state is NotificationPolicyState.Loaded) { + _state.value = state.copy(refreshing = true) + } else { + _state.value = NotificationPolicyState.Loading + } + } + + api.notificationPolicy().fold( + { policy -> + db.notificationPolicyDao().update( + NotificationPolicyEntity( + tuskyAccountId = accountId, + pendingRequestsCount = policy.summary.pendingRequestsCount, + pendingNotificationsCount = policy.summary.pendingNotificationsCount, + ) + ) + _state.value = NotificationPolicyState.Loaded(refreshing = false, policy = policy) + }, + { t -> + if (t is HttpException && t.code() == 404) { + _state.value = NotificationPolicyState.Unsupported + } else { + _state.value = NotificationPolicyState.Error(t) + } + } + ) + } + + suspend fun updatePolicy( + forNotFollowing: String? = null, + forNotFollowers: String? = null, + forNewAccounts: String? = null, + forPrivateMentions: String? = null, + forLimitedAccounts: String? = null + ): NetworkResult { + return api.updateNotificationPolicy( + forNotFollowing = forNotFollowing, + forNotFollowers = forNotFollowers, + forNewAccounts = forNewAccounts, + forPrivateMentions = forPrivateMentions, + forLimitedAccounts = forLimitedAccounts + ).onSuccess { notificationPolicy -> + _state.value = NotificationPolicyState.Loaded(false, notificationPolicy) + } + } + + suspend fun updateCounts(notificationCount: Int) = + db.notificationPolicyDao().updateCounts(accountId, notificationCount) +} + +sealed interface NotificationPolicyState { + + data object Loading : NotificationPolicyState + data object Unsupported : NotificationPolicyState + data class Error( + val throwable: Throwable + ) : NotificationPolicyState + data class Loaded( + val refreshing: Boolean, + val policy: NotificationPolicy + ) : NotificationPolicyState +} diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt index 24afc0637..2e1cfddcd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt @@ -20,26 +20,20 @@ import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.onFailure import at.connyduck.calladapter.networkresult.onSuccess -import at.connyduck.calladapter.networkresult.runCatching import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.MuteConversationEvent import com.keylesspalace.tusky.appstore.MuteEvent import com.keylesspalace.tusky.appstore.PollVoteEvent import com.keylesspalace.tusky.appstore.StatusChangedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.entity.DeletedStatus -import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Poll -import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Translation import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.Single import com.keylesspalace.tusky.util.getServerErrorMessage import java.util.Locale import javax.inject.Inject -import retrofit2.Response /** * Created by charlag on 3/24/18. @@ -50,9 +44,9 @@ class TimelineCases @Inject constructor( private val eventHub: EventHub ) { - suspend fun reblog(statusId: String, reblog: Boolean): NetworkResult { + suspend fun reblog(statusId: String, reblog: Boolean, visibility: Status.Visibility = Status.Visibility.PUBLIC): NetworkResult { return if (reblog) { - mastodonApi.reblogStatus(statusId) + mastodonApi.reblogStatus(statusId, visibility.stringValue) } else { mastodonApi.unreblogStatus(statusId) }.onSuccess { status -> @@ -66,10 +60,6 @@ class TimelineCases @Inject constructor( } } - fun reblogOld(statusId: String, reblog: Boolean): Single { - return Single { reblog(statusId, reblog) } - } - suspend fun favourite(statusId: String, favourite: Boolean): NetworkResult { return if (favourite) { mastodonApi.favouriteStatus(statusId) @@ -80,10 +70,6 @@ class TimelineCases @Inject constructor( } } - fun favouriteOld(statusId: String, favourite: Boolean): Single { - return Single { favourite(statusId, favourite) } - } - suspend fun bookmark(statusId: String, bookmark: Boolean): NetworkResult { return if (bookmark) { mastodonApi.bookmarkStatus(statusId) @@ -94,17 +80,13 @@ class TimelineCases @Inject constructor( } } - fun bookmarkOld(statusId: String, bookmark: Boolean): Single { - return Single { bookmark(statusId, bookmark) } - } - suspend fun muteConversation(statusId: String, mute: Boolean): NetworkResult { return if (mute) { mastodonApi.muteConversation(statusId) } else { mastodonApi.unmuteConversation(statusId) - }.onSuccess { - eventHub.dispatch(MuteConversationEvent(statusId, mute)) + }.onSuccess { status -> + eventHub.dispatch(StatusChangedEvent(status)) } } @@ -160,31 +142,6 @@ class TimelineCases @Inject constructor( } } - fun voteInPollOld(statusId: String, pollId: String, choices: List): Single { - return Single { voteInPoll(statusId, pollId, choices) } - } - - fun acceptFollowRequestOld(accountId: String): Single { - return Single { mastodonApi.authorizeFollowRequest(accountId) } - } - - fun rejectFollowRequestOld(accountId: String): Single { - return Single { mastodonApi.rejectFollowRequest(accountId) } - } - - fun notificationsOld( - maxId: String?, - sinceId: String?, - limit: Int?, - excludes: Set? - ): Single>> { - return Single { runCatching { mastodonApi.notifications(maxId, sinceId, limit, excludes) } } - } - - fun clearNotificationsOld(): Single { - return Single { mastodonApi.clearNotifications() } - } - suspend fun translate( statusId: String ): NetworkResult { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ActivityExensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ActivityExensions.kt deleted file mode 100644 index bfe27ecd7..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/ActivityExensions.kt +++ /dev/null @@ -1,25 +0,0 @@ -@file:JvmName("ActivityExtensions") - -package com.keylesspalace.tusky.util - -import android.app.Activity -import android.content.Intent -import android.os.Build -import com.keylesspalace.tusky.BaseActivity -import com.keylesspalace.tusky.R - -fun Activity.startActivityWithSlideInAnimation(intent: Intent) { - // the new transition api needs to be called by the activity that is the result of the transition, - // so we pass a flag that BaseActivity will respect. - intent.putExtra(BaseActivity.OPEN_WITH_SLIDE_IN, true) - startActivity(intent) - if (!supportsOverridingActivityTransitions()) { - // the old api needs to be called by the activity that starts the transition - @Suppress("DEPRECATION") - overridePendingTransition(R.anim.activity_open_enter, R.anim.activity_open_exit) - } -} - -fun supportsOverridingActivityTransitions(): Boolean { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ActivityExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ActivityExtensions.kt new file mode 100644 index 000000000..a58f4f45a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ActivityExtensions.kt @@ -0,0 +1,65 @@ +@file:JvmName("ActivityExtensions") + +package com.keylesspalace.tusky.util + +import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.annotation.AnimRes +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.keylesspalace.tusky.BaseActivity + +fun Activity.startActivityWithSlideInAnimation(intent: Intent) { + startActivity(intent.withSlideInAnimation()) +} + +fun Intent.withSlideInAnimation(): Intent { + // the new transition api needs to be called by the activity that is the result of the transition, + // so we pass a flag that BaseActivity will respect. + return putExtra(BaseActivity.OPEN_WITH_SLIDE_IN, true) +} + +/** + * Call this method in Activity.onCreate() to configure the open or close transitions. + */ +@Suppress("DEPRECATION") +fun ComponentActivity.overrideActivityTransitionCompat( + overrideType: Int, + @AnimRes enterAnim: Int, + @AnimRes exitAnim: Int +) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + overrideActivityTransition(overrideType, enterAnim, exitAnim) + } else { + if (overrideType == ActivityConstants.OVERRIDE_TRANSITION_OPEN) { + overridePendingTransition(enterAnim, exitAnim) + } else { + lifecycle.addObserver( + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_PAUSE && isFinishing) { + overridePendingTransition(enterAnim, exitAnim) + } + } + ) + } + } +} + +fun Activity.copyToClipboard(text: CharSequence, popupText: CharSequence, clipboardLabel: CharSequence = "") { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText(clipboardLabel, text)) + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + Toast.makeText(this, popupText, Toast.LENGTH_SHORT).show() + } +} + +object ActivityConstants { + const val OVERRIDE_TRANSITION_OPEN = 0 + const val OVERRIDE_TRANSITION_CLOSE = 1 +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/AlertDialogExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/AlertDialogExtensions.kt index 70362db50..fb5184ac3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/AlertDialogExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/AlertDialogExtensions.kt @@ -20,7 +20,6 @@ package com.keylesspalace.tusky.util import android.content.DialogInterface import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.suspendCancellableCoroutine /** @@ -30,14 +29,13 @@ import kotlinx.coroutines.suspendCancellableCoroutine * @param negativeText Optional text to show on the negative button * @param neutralText Optional text to show on the neutral button */ -@OptIn(ExperimentalCoroutinesApi::class) suspend fun AlertDialog.await( positiveText: String, negativeText: String? = null, neutralText: String? = null ) = suspendCancellableCoroutine { cont -> val listener = DialogInterface.OnClickListener { _, which -> - cont.resume(which) { dismiss() } + cont.resume(which) { _, _, _ -> dismiss() } } setButton(AlertDialog.BUTTON_POSITIVE, positiveText, listener) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/AsciiFolding.kt b/app/src/main/java/com/keylesspalace/tusky/util/AsciiFolding.kt index 8f1101d29..a0f073505 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/AsciiFolding.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/AsciiFolding.kt @@ -21,6 +21,6 @@ val unicodeToASCIIMap = "ÀÁÂÃÄÅàáâãäåĀāĂ㥹ÇçĆćĈĉĊċČ "AAAAAAaaaaaaAaAaAaCcCcCcCcCcDdDdDdEEEEeeeeEeEeEeEeEeGgGgGgGgHhHhIIIIiiiiIiIiIiIiIiJjKkkLlLlLlLlLlNnNnNnNnnNnOOOOOOooooooOoOoOoRrRrRrSsSsSsSssTtTtTtUUUUuuuuUuUuUuUuUuUuWwYyyYyYZzZzZz".toList() ).toMap() -fun normalizeToASCII(text: CharSequence): CharSequence { +fun normalizeToASCII(text: CharSequence): String { return String(text.map { unicodeToASCIIMap[it] ?: it }.toCharArray()) } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/AttachmentHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/AttachmentHelper.kt index f49b901a0..287e3e774 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/AttachmentHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/AttachmentHelper.kt @@ -6,24 +6,22 @@ import android.content.Context import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.Attachment import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.seconds fun Attachment.getFormattedDescription(context: Context): CharSequence { - var duration = "" - if (meta?.duration != null && meta.duration > 0) { - duration = formatDuration(meta.duration.toDouble()) + " " - } - return if (description.isNullOrEmpty()) { - duration + context.getString(R.string.description_post_media_no_description_placeholder) + val durationInSeconds = meta?.duration ?: 0f + val duration = if (durationInSeconds > 0f) { + durationInSeconds.roundToInt().seconds.toComponents { hours, minutes, seconds, _ -> + "%d:%02d:%02d ".format(hours, minutes, seconds) + } } else { - duration + description + "" + } + return duration + if (description.isNullOrEmpty()) { + context.getString(R.string.description_post_media_no_description_placeholder) + } else { + description } -} - -private fun formatDuration(durationInSeconds: Double): String { - val seconds = durationInSeconds.roundToInt() % 60 - val minutes = durationInSeconds.toInt() % 3600 / 60 - val hours = durationInSeconds.toInt() / 3600 - return "%d:%02d:%02d".format(hours, minutes, seconds) } fun List.aspectRatios(): List { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/BlurHashDecoder.kt b/app/src/main/java/com/keylesspalace/tusky/util/BlurHashDecoder.kt index 117f59c09..940f5f138 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/BlurHashDecoder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/BlurHashDecoder.kt @@ -2,6 +2,7 @@ * Blurhash implementation from blurhash project: * https://github.com/woltapp/blurhash * Minor modifications by charlag + * Major performance improvements by cbeyls */ package com.keylesspalace.tusky.util @@ -24,28 +25,27 @@ object BlurHashDecoder { val numCompEnc = decode83(blurHash, 0, 1) val numCompX = (numCompEnc % 9) + 1 val numCompY = (numCompEnc / 9) + 1 - if (blurHash.length != 4 + 2 * numCompX * numCompY) { + val totalComp = numCompX * numCompY + if (blurHash.length != 4 + 2 * totalComp) { return null } val maxAcEnc = decode83(blurHash, 1, 2) val maxAc = (maxAcEnc + 1) / 166f - val colors = Array(numCompX * numCompY) { i -> - if (i == 0) { - val colorEnc = decode83(blurHash, 2, 6) - decodeDc(colorEnc) - } else { - val from = 4 + i * 2 - val colorEnc = decode83(blurHash, from, from + 2) - decodeAc(colorEnc, maxAc * punch) - } + val colors = FloatArray(totalComp * 3) + var colorEnc = decode83(blurHash, 2, 6) + decodeDc(colorEnc, colors) + for (i in 1 until totalComp) { + val from = 4 + i * 2 + colorEnc = decode83(blurHash, from, from + 2) + decodeAc(colorEnc, maxAc * punch, colors, i * 3) } return composeBitmap(width, height, numCompX, numCompY, colors) } - private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int { + private fun decode83(str: String, from: Int, to: Int): Int { var result = 0 for (i in from until to) { - val index = charMap[str[i]] ?: -1 + val index = CHARS.indexOf(str[i]) if (index != -1) { result = result * 83 + index } @@ -53,11 +53,13 @@ object BlurHashDecoder { return result } - private fun decodeDc(colorEnc: Int): FloatArray { - val r = colorEnc shr 16 - val g = (colorEnc shr 8) and 255 - val b = colorEnc and 255 - return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)) + private fun decodeDc(colorEnc: Int, outArray: FloatArray) { + val r = (colorEnc shr 16) and 0xFF + val g = (colorEnc shr 8) and 0xFF + val b = colorEnc and 0xFF + outArray[0] = srgbToLinear(r) + outArray[1] = srgbToLinear(g) + outArray[2] = srgbToLinear(b) } private fun srgbToLinear(colorEnc: Int): Float { @@ -69,15 +71,13 @@ object BlurHashDecoder { } } - private fun decodeAc(value: Int, maxAc: Float): FloatArray { + private fun decodeAc(value: Int, maxAc: Float, outArray: FloatArray, outIndex: Int) { val r = value / (19 * 19) val g = (value / 19) % 19 val b = value % 19 - return floatArrayOf( - signedPow2((r - 9) / 9.0f) * maxAc, - signedPow2((g - 9) / 9.0f) * maxAc, - signedPow2((b - 9) / 9.0f) * maxAc - ) + outArray[outIndex] = signedPow2((r - 9) / 9.0f) * maxAc + outArray[outIndex + 1] = signedPow2((g - 9) / 9.0f) * maxAc + outArray[outIndex + 2] = signedPow2((b - 9) / 9.0f) * maxAc } private fun signedPow2(value: Float) = value.pow(2f).withSign(value) @@ -87,21 +87,29 @@ object BlurHashDecoder { height: Int, numCompX: Int, numCompY: Int, - colors: Array + colors: FloatArray ): Bitmap { val imageArray = IntArray(width * height) + val cosinesX = createCosines(width, numCompX) + val cosinesY = if (width == height && numCompX == numCompY) { + cosinesX + } else { + createCosines(height, numCompY) + } for (y in 0 until height) { for (x in 0 until width) { var r = 0f var g = 0f var b = 0f for (j in 0 until numCompY) { + val cosY = cosinesY[y * numCompY + j] for (i in 0 until numCompX) { - val basis = (cos(PI * x * i / width) * cos(PI * y * j / height)).toFloat() - val color = colors[j * numCompX + i] - r += color[0] * basis - g += color[1] * basis - b += color[2] * basis + val cosX = cosinesX[x * numCompX + i] + val basis = cosX * cosY + val colorIndex = (j * numCompX + i) * 3 + r += colors[colorIndex] * basis + g += colors[colorIndex + 1] * basis + b += colors[colorIndex + 2] * basis } } imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) @@ -110,6 +118,12 @@ object BlurHashDecoder { return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) } + private fun createCosines(size: Int, numComp: Int) = FloatArray(size * numComp) { index -> + val x = index / numComp + val i = index % numComp + cos(PI * x * i / size).toFloat() + } + private fun linearToSrgb(value: Float): Int { val v = value.coerceIn(0f, 1f) return if (v <= 0.0031308f) { @@ -119,13 +133,5 @@ object BlurHashDecoder { } } - private val charMap = listOf( - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', - 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', - 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', - 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',', - '-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~' - ) - .mapIndexed { i, c -> c to i } - .toMap() + private const val CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/BlurhashDrawable.kt b/app/src/main/java/com/keylesspalace/tusky/util/BlurhashDrawable.kt new file mode 100644 index 000000000..4c6fe9a2e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/BlurhashDrawable.kt @@ -0,0 +1,39 @@ +/* Copyright 2025 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.util + +import android.content.Context +import android.graphics.drawable.BitmapDrawable + +/** + * Drawable to display blurhashes with custom equals and hashCode implementation. + * This is so Glide does not flicker unnecessarily when it is used with blurhashes as placeholder. + */ +class BlurhashDrawable( + context: Context, + val blurhash: String +) : BitmapDrawable( + context.resources, + BlurHashDecoder.decode(blurhash, 32, 32, 1f) +) { + override fun equals(other: Any?): Boolean { + return (other as? BlurhashDrawable)?.blurhash == blurhash + } + + override fun hashCode(): Int { + return blurhash.hashCode() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/BundleExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/BundleExtensions.kt new file mode 100644 index 000000000..4aea34920 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/BundleExtensions.kt @@ -0,0 +1,23 @@ +package com.keylesspalace.tusky.util + +import android.content.Intent +import android.os.Bundle +import android.os.Parcelable +import androidx.core.content.IntentCompat +import androidx.core.os.BundleCompat +import java.io.Serializable + +inline fun Bundle.getParcelableCompat(key: String?): T? = + BundleCompat.getParcelable(this, key, T::class.java) + +inline fun Bundle.getSerializableCompat(key: String?): T? = + BundleCompat.getSerializable(this, key, T::class.java) + +inline fun Intent.getParcelableExtraCompat(key: String?): T? = + IntentCompat.getParcelableExtra(this, key, T::class.java) + +inline fun Intent.getSerializableExtraCompat(key: String?): T? = + IntentCompat.getSerializableExtra(this, key, T::class.java) + +inline fun Intent.getParcelableArrayListExtraCompat(key: String?): ArrayList? = + IntentCompat.getParcelableArrayListExtra(this, key, T::class.java) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt index 0ef40b312..53290d075 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt @@ -21,60 +21,97 @@ import android.graphics.Canvas import android.graphics.Paint import android.graphics.drawable.Animatable import android.graphics.drawable.Drawable -import android.text.SpannableStringBuilder import android.text.style.ReplacementSpan import android.view.View import android.widget.TextView +import androidx.core.text.toSpannable import com.bumptech.glide.Glide import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.Target import com.bumptech.glide.request.transition.Transition import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.Emoji -import java.lang.ref.WeakReference -import java.util.regex.Pattern /** * replaces emoji shortcodes in a text with EmojiSpans * @receiver the text containing custom emojis - * @param emojis a list of the custom emojis (nullable for backward compatibility with old mastodon instances) + * @param emojis a list of the custom emojis * @param view a reference to the a view the emojis will be shown in (should be the TextView, but parents of the TextView are also acceptable) * @return the text with the shortcodes replaced by EmojiSpans */ fun CharSequence.emojify(emojis: List, view: View, animate: Boolean): CharSequence { - if (emojis.isEmpty()) { - return this + return view.updateEmojiTargets { + emojify(emojis, animate) } +} - val builder = SpannableStringBuilder.valueOf(this) +class EmojiTargetScope(val view: T) { + private val _targets = mutableListOf>() + val targets: List> + get() = _targets - emojis.forEach { (shortcode, url, staticUrl) -> - val matcher = Pattern.compile(":$shortcode:", Pattern.LITERAL) - .matcher(this) - - while (matcher.find()) { - val span = EmojiSpan(view) - - builder.setSpan(span, matcher.start(), matcher.end(), 0) - Glide.with(view) - .asDrawable() - .load( - if (animate) { - url - } else { - staticUrl - } - ) - .into(span.getTarget(animate)) + fun CharSequence.emojify(emojis: List, animate: Boolean): CharSequence { + if (emojis.isEmpty()) { + return this } + + val spannable = toSpannable() + val requestManager = Glide.with(view) + + emojis.forEach { (shortcode, url, staticUrl) -> + val pattern = ":$shortcode:" + var start = indexOf(pattern) + + while (start != -1) { + val end = start + pattern.length + val span = EmojiSpan(view) + + spannable.setSpan(span, start, end, 0) + val target = span.createGlideTarget(view, animate) + requestManager + .asDrawable() + .load( + if (animate) { + url + } else { + staticUrl + } + ) + .into(target) + _targets.add(target) + + start = indexOf(pattern, end) + } + } + + return spannable } - return builder +} + +inline fun T.updateEmojiTargets(body: EmojiTargetScope.() -> R): R { + clearEmojiTargets() + val scope = EmojiTargetScope(this) + val result = body(scope) + setEmojiTargets(scope.targets) + return result +} + +@Suppress("UNCHECKED_CAST") +fun View.clearEmojiTargets() { + getTag(R.id.custom_emoji_targets_tag)?.let { tag -> + val targets = tag as List> + val requestManager = Glide.with(this) + targets.forEach { requestManager.clear(it) } + setTag(R.id.custom_emoji_targets_tag, null) + } +} + +fun View.setEmojiTargets(targets: List>) { + setTag(R.id.custom_emoji_targets_tag, targets.takeIf { it.isNotEmpty() }) } class EmojiSpan(view: View) : ReplacementSpan() { - private val viewWeakReference = WeakReference(view) - private val emojiSize: Int = if (view is TextView) { view.paint.textSize } else { @@ -147,34 +184,52 @@ class EmojiSpan(view: View) : ReplacementSpan() { } } - fun getTarget(animate: Boolean): Target { + fun createGlideTarget(view: View, animate: Boolean): Target { return object : CustomTarget(emojiSize, emojiSize) { - override fun onResourceReady(resource: Drawable, transition: Transition?) { - viewWeakReference.get()?.let { view -> - if (animate && resource is Animatable) { - val callback = resource.callback - - resource.callback = object : Drawable.Callback { - override fun unscheduleDrawable(p0: Drawable, p1: Runnable) { - callback?.unscheduleDrawable(p0, p1) - } - override fun scheduleDrawable(p0: Drawable, p1: Runnable, p2: Long) { - callback?.scheduleDrawable(p0, p1, p2) - } - override fun invalidateDrawable(p0: Drawable) { - callback?.invalidateDrawable(p0) - view.invalidate() - } - } - resource.start() - } - - imageDrawable = resource - view.invalidate() - } + override fun onStart() { + (imageDrawable as? Animatable)?.start() } - override fun onLoadCleared(placeholder: Drawable?) {} + override fun onStop() { + (imageDrawable as? Animatable)?.stop() + } + + override fun onLoadFailed(errorDrawable: Drawable?) { + // Nothing to do + } + + override fun onResourceReady(resource: Drawable, transition: Transition?) { + if (animate && resource is Animatable) { + resource.callback = object : Drawable.Callback { + override fun invalidateDrawable(who: Drawable) { + view.invalidate() + } + + override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) { + view.postDelayed(what, `when`) + } + + override fun unscheduleDrawable(who: Drawable, what: Runnable) { + view.removeCallbacks(what) + } + } + resource.start() + } + + imageDrawable = resource + view.invalidate() + } + + override fun onLoadCleared(placeholder: Drawable?) { + imageDrawable?.let { currentDrawable -> + if (currentDrawable is Animatable) { + currentDrawable.stop() + currentDrawable.callback = null + } + } + imageDrawable = null + view.invalidate() + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Either.kt b/app/src/main/java/com/keylesspalace/tusky/util/Either.kt deleted file mode 100644 index 3b26c3daa..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/Either.kt +++ /dev/null @@ -1,49 +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.util - -/** - * Created by charlag on 05/11/17. - * - * Class to represent sum type/tagged union/variant/ADT e.t.c. - * It is either Left or Right. - */ -sealed interface Either { - data class Left(val value: L) : Either - data class Right(val value: R) : Either - - fun isRight(): Boolean = this is Right - - fun isLeft(): Boolean = this is Left - - fun asLeftOrNull(): L? = (this as? Left)?.value - - fun asRightOrNull(): R? = (this as? Right)?.value - - fun asLeft(): L = (this as Left).value - - fun asRight(): R = (this as Right).value - - companion object { - inline fun Either.map(mapper: (R) -> N): Either { - return if (this.isLeft()) { - Left(this.asLeft()) - } else { - Right(mapper(this.asRight())) - } - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/EmptyPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/util/EmptyPagingSource.kt deleted file mode 100644 index d08a9c129..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/EmptyPagingSource.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.keylesspalace.tusky.util - -import androidx.paging.PagingSource -import androidx.paging.PagingState - -class EmptyPagingSource : PagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null - - override suspend fun load(params: LoadParams): LoadResult = LoadResult.Page( - emptyList(), - null, - null - ) -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt deleted file mode 100644 index 54eaa8a23..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.util - -import kotlin.time.Duration -import kotlin.time.TimeMark -import kotlin.time.TimeSource -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow - -/** - * Returns a flow that mirrors the original flow, but filters out values that occur within - * [timeout] of the previously emitted value. The first value is always emitted. - * - * Example: - * - * ```kotlin - * flow { - * emit(1) - * delay(90.milliseconds) - * emit(2) - * delay(90.milliseconds) - * emit(3) - * delay(1010.milliseconds) - * emit(4) - * delay(1010.milliseconds) - * emit(5) - * }.throttleFirst(1000.milliseconds) - * ``` - * - * produces the following emissions. - * - * ```text - * 1, 4, 5 - * ``` - * - * @see kotlinx.coroutines.flow.debounce(Duration) - * @param timeout Emissions within this duration of the last emission are filtered - * @param timeSource Used to measure elapsed time. Normally only overridden in tests - */ -fun Flow.throttleFirst(timeout: Duration, timeSource: TimeSource = TimeSource.Monotonic) = - flow { - var marker: TimeMark? = null - collect { - if (marker == null || marker!!.elapsedNow() >= timeout) { - emit(it) - marker = timeSource.markNow() - } - } - } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/FocalPointUtil.kt b/app/src/main/java/com/keylesspalace/tusky/util/FocalPointUtil.kt index d26d70cef..28bfdd694 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/FocalPointUtil.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/FocalPointUtil.kt @@ -72,9 +72,9 @@ object FocalPointUtil { var top = 0f var left = 0f if (isVerticalCrop(viewWidth, viewHeight, imageWidth, imageHeight)) { - top = focalOffset(viewHeight, imageHeight, scale, focalYToCoordinate(focus.y)) + top = focalOffset(viewHeight, imageHeight, scale, focalYToCoordinate(focus.y ?: 0f)) } else { // horizontal crop - left = focalOffset(viewWidth, imageWidth, scale, focalXToCoordinate(focus.x)) + left = focalOffset(viewWidth, imageWidth, scale, focalXToCoordinate(focus.x ?: 0f)) } mat.postTranslate(left, top) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/GlideExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/GlideExtensions.kt index bc5ad2b3a..a328a8e7c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/GlideExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/GlideExtensions.kt @@ -5,6 +5,7 @@ import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target +import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlinx.coroutines.suspendCancellableCoroutine @@ -13,34 +14,45 @@ import kotlinx.coroutines.suspendCancellableCoroutine * Allows waiting for a Glide request to complete without blocking a background thread. */ suspend fun RequestBuilder.submitAsync( - width: Int = Int.MIN_VALUE, - height: Int = Int.MIN_VALUE + width: Int = Target.SIZE_ORIGINAL, + height: Int = Target.SIZE_ORIGINAL ): R { return suspendCancellableCoroutine { continuation -> - val target = addListener( - object : RequestListener { - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target, - isFirstResource: Boolean - ): Boolean { - continuation.resumeWithException(e ?: GlideException("Image loading failed")) - return false - } - - override fun onResourceReady( - resource: R & Any, - model: Any, - target: Target?, - dataSource: DataSource, - isFirstResource: Boolean - ): Boolean { - continuation.resume(resource) - return false - } - } - ).submit(width, height) + val target = addListener(ContinuationRequestListener(continuation)) + .submit(width, height) continuation.invokeOnCancellation { target.cancel(true) } } } + +private class ContinuationRequestListener(continuation: Continuation) : RequestListener { + private var continuation: Continuation? = continuation + + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target, + isFirstResource: Boolean + ): Boolean { + continuation?.let { + continuation = null + it.resumeWithException(e ?: GlideException("Image loading failed")) + } + return false + } + + override fun onResourceReady( + resource: R & Any, + model: Any, + target: Target?, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { + continuation?.let { + if (target?.request?.isComplete == true) { + continuation = null + it.resume(resource) + } + } + return false + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/IconUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/IconUtils.kt index 3cb34fe46..334f12fc4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/IconUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/IconUtils.kt @@ -15,9 +15,10 @@ package com.keylesspalace.tusky.util -import android.content.Context import android.graphics.Color -import androidx.annotation.Px +import android.graphics.drawable.Drawable +import androidx.appcompat.content.res.AppCompatResources +import androidx.preference.PreferenceFragmentCompat import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.mikepenz.iconics.IconicsDrawable @@ -25,9 +26,19 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizePx -fun makeIcon(context: Context, icon: GoogleMaterial.Icon, @Px iconSize: Int): IconicsDrawable { +fun PreferenceFragmentCompat.icon(icon: GoogleMaterial.Icon): IconicsDrawable { + val context = requireContext() return IconicsDrawable(context, icon).apply { - sizePx = iconSize + sizePx = context.resources.getDimensionPixelSize( + R.dimen.preference_icon_size + ) colorInt = MaterialColors.getColor(context, R.attr.iconColor, Color.BLACK) } } + +fun PreferenceFragmentCompat.icon(icon: Int): Drawable? { + val context = requireContext() + return AppCompatResources.getDrawable(context, icon)?.apply { + setTint(MaterialColors.getColor(context, R.attr.iconColor, Color.BLACK)) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt index 0979f5964..641c78fdd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt @@ -1,10 +1,23 @@ +/* Copyright 2025 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 . */ + @file:JvmName("ImageLoadingHelper") package com.keylesspalace.tusky.util -import android.content.Context import android.graphics.Bitmap -import android.graphics.drawable.BitmapDrawable import android.widget.ImageView import androidx.annotation.Px import com.bumptech.glide.Glide @@ -52,7 +65,3 @@ fun loadAvatar( } } } - -fun decodeBlurHash(context: Context, blurhash: String): BitmapDrawable { - return BitmapDrawable(context.resources, BlurHashDecoder.decode(blurhash, 32, 32, 1f)) -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LifecycleExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/LifecycleExtensions.kt new file mode 100644 index 000000000..c0d99b6ec --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/LifecycleExtensions.kt @@ -0,0 +1,21 @@ +package com.keylesspalace.tusky.util + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.coroutineScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +fun Lifecycle.launchAndRepeatOnLifecycle( + state: Lifecycle.State = Lifecycle.State.STARTED, + block: suspend CoroutineScope.() -> Unit +): Job = coroutineScope.launch { + repeatOnLifecycle(state, block) +} + +fun LifecycleOwner.launchAndRepeatOnLifecycle( + state: Lifecycle.State = Lifecycle.State.STARTED, + block: suspend CoroutineScope.() -> Unit +): Job = lifecycle.launchAndRepeatOnLifecycle(state, block) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt index 50d3ccfa7..b41b0e88c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt @@ -43,13 +43,16 @@ import androidx.browser.customtabs.CustomTabsIntent import androidx.core.net.toUri import androidx.preference.PreferenceManager import at.connyduck.sparkbutton.helpers.Utils +import com.google.android.material.R as materialR import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Status.Mention import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.settings.PrefKeys import java.net.URI import java.net.URISyntaxException +import java.util.regex.Pattern fun getDomain(urlString: String?): String { val host = urlString?.toUri()?.host @@ -68,27 +71,122 @@ fun getDomain(urlString: String?): String { * @param content containing text with mentions, links, or hashtags * @param mentions any '@' mentions which are known to be in the content * @param listener to notify about particular spans that are clicked + * @param trailingHashtagView a text view to fill with trailing / out-of-band hashtags */ fun setClickableText( view: TextView, content: CharSequence, mentions: List, tags: List?, - listener: LinkListener + listener: LinkListener, + trailingHashtagView: TextView? = null, ) { val spannableContent = markupHiddenUrls(view, content) + val (endOfContent, trailingHashtags) = when { + trailingHashtagView == null || tags.isNullOrEmpty() -> Pair(spannableContent.length, emptyList()) + else -> getTrailingHashtags(spannableContent) + } + val inlineHashtags = mutableSetOf() view.text = spannableContent.apply { styleQuoteSpans(view) - getSpans(0, spannableContent.length, URLSpan::class.java).forEach { span -> + getSpans(0, endOfContent, URLSpan::class.java).forEach { span -> + val start = getSpanStart(span) + if (get(start) == '#') { + inlineHashtags.add(normalizeToASCII(subSequence(start + 1, getSpanEnd(span)))) + } setClickableText(span, this, mentions, tags, listener) } + }.subSequence(0, endOfContent).trimEnd() + + view.movementMethod = NoTrailingSpaceLinkMovementMethod + + val showHashtagBar = (trailingHashtags.isNotEmpty() || inlineHashtags.size != tags?.size) + // I don't _love_ setting the visibility here, but the alternative is to duplicate the logic in other places + trailingHashtagView?.visible(showHashtagBar) + + if (showHashtagBar) { + trailingHashtagView?.apply { + text = buildTrailingHashtagText( + tags?.filterNot { tag -> inlineHashtags.any { it.contentEquals(tag.name, ignoreCase = true) } }, + trailingHashtags, + listener, + ) + } } - view.movementMethod = NoTrailingSpaceLinkMovementMethod.getInstance() } +/** + * Build a spanned string containing trailing and out-of-band hashtags for the trailing hashtag view + * @param tagsFromServer The list of hashtags from the server + * @param trailingHashtagsFromContent The list of trailing hashtags scraped from the post content + * @param listener to notify about particular spans that are clicked + */ +private fun buildTrailingHashtagText(tagsFromServer: List?, trailingHashtagsFromContent: List, listener: LinkListener): SpannableStringBuilder { + return SpannableStringBuilder().apply { + // we apply the tags scraped from the content first to preserve the casing + // (tags from the server are often downcased) + val additionalTags = tagsFromServer?.let { + it.filter { serverTag -> + trailingHashtagsFromContent.none { + serverTag.name.equals(normalizeToASCII(it.name), ignoreCase = true) + } + } + } ?: emptyList() + appendTags(trailingHashtagsFromContent.plus(additionalTags), listener) + } +} + +/** + * Append space-separated url spans for a list of hashtags + * @param tags The tags to append + * @param listener to notify about particular spans that are clicked + */ +private fun SpannableStringBuilder.appendTags(tags: List, listener: LinkListener) { + tags.forEachIndexed { index, tag -> + append("#${tag.name}", getCustomSpanForTag(normalizeToASCII(tag.name), URLSpan(tag.url), listener), 0) + if (index != tags.lastIndex) { + append(" ") + } + } +} + +private val hashtagWithHashPattern = Pattern.compile("^#$HASHTAG_EXPRESSION$") +private val whitespacePattern = Regex("""\s+""") + +/** + * Find the "trailing" hashtags in spanned content + * These are hashtags in lines consisting *only* of hashtags at the end of the post + */ @VisibleForTesting -fun markupHiddenUrls(view: TextView, content: CharSequence): SpannableStringBuilder { +internal fun getTrailingHashtags(content: Spanned): Pair> { + // split() instead of lines() because we need to be able to account for the length of the removed delimiter + val trailingContentLength = content.split('\r', '\n').asReversed().takeWhile { line -> + line.splitToSequence(whitespacePattern).all { it.isBlank() || hashtagWithHashPattern.matcher(it).matches() } + }.sumOf { it.length + 1 } // length + 1 to include the stripped line ending character + + return when (trailingContentLength) { + 0 -> Pair(content.length, emptyList()) + else -> { + val trailingContentOffset = (content.length - trailingContentLength).coerceAtLeast(0) + Pair( + trailingContentOffset, + content.getSpans(trailingContentOffset, content.length, URLSpan::class.java) + .filter { content[content.getSpanStart(it)] == '#' } // just in case + .map { spanToHashtag(content, it) } + ) + } + } +} + +// URLSpan("#tag", url) -> Hashtag("tag", url) +private fun spanToHashtag(content: Spanned, span: URLSpan) = HashTag( + content.subSequence(content.getSpanStart(span) + 1, content.getSpanEnd(span)).toString(), + span.url, +) + +@VisibleForTesting +internal fun markupHiddenUrls(view: TextView, content: CharSequence): SpannableStringBuilder { val spannableContent = SpannableStringBuilder(content) val originalSpans = spannableContent.getSpans(0, content.length, URLSpan::class.java) val obscuredLinkSpans = originalSpans.filter { @@ -113,29 +211,28 @@ fun markupHiddenUrls(view: TextView, content: CharSequence): SpannableStringBuil for (span in obscuredLinkSpans) { val start = spannableContent.getSpanStart(span) val end = spannableContent.getSpanEnd(span) - val originalText = spannableContent.subSequence(start, end) - val replacementText = view.context.getString( + val additionalText = " " + view.context.getString( R.string.url_domain_notifier, - originalText, getDomain(span.url) ) - spannableContent.replace( - start, + spannableContent.insert( end, - replacementText - ) // this also updates the span locations + additionalText + ) + // reinsert the span so it covers the original and the additional text + spannableContent.setSpan(span, start, end + additionalText.length, 0) val linkDrawable = AppCompatResources.getDrawable(view.context, R.drawable.ic_link)!! // ImageSpan does not always align the icon correctly in the line, let's use our custom emoji span for this val linkDrawableSpan = EmojiSpan(view) linkDrawableSpan.imageDrawable = linkDrawable - val placeholderIndex = replacementText.indexOf("🔗") + val placeholderIndex = end + 2 spannableContent.setSpan( linkDrawableSpan, - start + placeholderIndex, - start + placeholderIndex + "🔗".length, + placeholderIndex, + placeholderIndex + "🔗".length, 0 ) } @@ -170,7 +267,7 @@ fun setClickableText( @VisibleForTesting fun getTagName(text: CharSequence, tags: List?): String? { - val scrapedName = normalizeToASCII(text.subSequence(1, text.length)).toString() + val scrapedName = normalizeToASCII(text.subSequence(1, text.length)) return when (tags) { null -> scrapedName else -> tags.firstOrNull { it.name.equals(scrapedName, true) }?.name @@ -182,12 +279,14 @@ private fun getCustomSpanForTag( tags: List?, span: URLSpan, listener: LinkListener -): ClickableSpan? { - return getTagName(text, tags)?.let { - object : NoUnderlineURLSpan(span.url) { - override fun onClick(view: View) = listener.onViewTag(it) - } - } +) = getTagName(text, tags)?.let { getCustomSpanForTag(it, span, listener) } + +private fun getCustomSpanForTag( + tagName: String, + span: URLSpan, + listener: LinkListener +) = object : NoUnderlineURLSpan(span.url) { + override fun onClick(view: View) = listener.onViewTag(tagName) } private fun getCustomSpanForMention( @@ -275,7 +374,7 @@ fun setClickableMentions(view: TextView, mentions: List?, listener: Lin start = end } } - view.movementMethod = NoTrailingSpaceLinkMovementMethod.getInstance() + view.movementMethod = NoTrailingSpaceLinkMovementMethod } fun createClickableText(text: String, link: String): CharSequence { @@ -294,7 +393,7 @@ fun Context.openLink(url: String) { val uri = url.toUri().normalizeScheme() val useCustomTabs = PreferenceManager.getDefaultSharedPreferences( this - ).getBoolean("customTabs", false) + ).getBoolean(PrefKeys.CUSTOM_TABS, false) if (useCustomTabs) { openLinkInCustomTab(uri, this) @@ -328,7 +427,7 @@ private fun openLinkInBrowser(uri: Uri?, context: Context) { fun openLinkInCustomTab(uri: Uri, context: Context) { val toolbarColor = MaterialColors.getColor( context, - com.google.android.material.R.attr.colorSurface, + materialR.attr.colorSurface, Color.BLACK ) val navigationbarColor = MaterialColors.getColor( @@ -441,6 +540,4 @@ object NoTrailingSpaceLinkMovementMethod : LinkMovementMethod() { return super.onTouchEvent(widget, buffer, event) } - - fun getInstance() = NoTrailingSpaceLinkMovementMethod } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt index 537c7acdc..82f43bf59 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt @@ -8,12 +8,12 @@ import android.view.View import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityManager import android.widget.ArrayAdapter -import androidx.appcompat.app.AlertDialog import androidx.core.view.AccessibilityDelegateCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.entity.Status.Companion.MAX_MEDIA_ATTACHMENTS @@ -174,7 +174,7 @@ class ListStatusAccessibilityDelegate( val status = getStatus(host) as? StatusViewData.Concrete ?: return val links = getLinks(status).toList() val textLinks = links.map { item -> item.link } - AlertDialog.Builder(host.context) + MaterialAlertDialogBuilder(host.context) .setTitle(R.string.title_links_dialog) .setAdapter( ArrayAdapter( @@ -191,7 +191,7 @@ class ListStatusAccessibilityDelegate( val status = getStatus(host) as? StatusViewData.Concrete ?: return val mentions = status.actionable.mentions val stringMentions = mentions.map { it.username } - AlertDialog.Builder(host.context) + MaterialAlertDialogBuilder(host.context) .setTitle(R.string.title_mentions_dialog) .setAdapter( ArrayAdapter( @@ -209,7 +209,7 @@ class ListStatusAccessibilityDelegate( private fun showHashtagsDialog(host: View) { val status = getStatus(host) as? StatusViewData.Concrete ?: return val tags = getHashtags(status).map { it.subSequence(1, it.length) }.toList() - AlertDialog.Builder(host.context) + MaterialAlertDialogBuilder(host.context) .setTitle(R.string.title_hashtags_dialog) .setAdapter( ArrayAdapter( diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt index e7f03fca4..12e5b73d1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt @@ -17,38 +17,29 @@ package com.keylesspalace.tusky.util -import java.util.ArrayList -import java.util.LinkedHashSet - /** - * @return true if list is null or else return list.isEmpty() + * Copies elements to destination, removing duplicates and preserving original order. */ -fun isEmpty(list: List<*>?): Boolean { - return list == null || list.isEmpty() -} - -/** - * @return a new ArrayList containing the elements without duplicates in the same order - */ -fun removeDuplicates(list: List): ArrayList { - val set = LinkedHashSet(list) - return ArrayList(set) +fun > Iterable.removeDuplicatesTo(destination: C): C { + return filterTo(destination, HashSet()::add) } inline fun List.withoutFirstWhich(predicate: (T) -> Boolean): List { - val newList = toMutableList() - val index = newList.indexOfFirst(predicate) - if (index != -1) { - newList.removeAt(index) + val index = indexOfFirst(predicate) + if (index == -1) { + return this } + val newList = toMutableList() + newList.removeAt(index) return newList } inline fun List.replacedFirstWhich(replacement: T, predicate: (T) -> Boolean): List { - val newList = toMutableList() - val index = newList.indexOfFirst(predicate) - if (index != -1) { - newList[index] = replacement + val index = indexOfFirst(predicate) + if (index == -1) { + return this } + val newList = toMutableList() + newList[index] = replacement return newList } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt b/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt index 8a5dc3d26..8f3d2a7c9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt @@ -21,21 +21,22 @@ import android.os.Build import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat import androidx.preference.PreferenceDataStore -import androidx.preference.PreferenceManager import com.keylesspalace.tusky.R import com.keylesspalace.tusky.settings.PrefKeys +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton @Singleton class LocaleManager @Inject constructor( - val context: Context + @ApplicationContext val context: Context ) : PreferenceDataStore() { - private var prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + @Inject + lateinit var preferences: SharedPreferences fun setLocale() { - val language = prefs.getNonNullString(PrefKeys.LANGUAGE, DEFAULT) + val language = preferences.getNonNullString(PrefKeys.LANGUAGE, DEFAULT) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (language != HANDLED_BY_SYSTEM) { @@ -43,7 +44,7 @@ class LocaleManager @Inject constructor( // hand over the old setting to the system and save a dummy value in Shared Preferences applyLanguageToApp(language) - prefs.edit() + preferences.edit() .putString(PrefKeys.LANGUAGE, HANDLED_BY_SYSTEM) .apply() } @@ -57,7 +58,7 @@ class LocaleManager @Inject constructor( // if we are on Android < 13 we have to save the selected language so we can apply it at appstart // on Android 13+ the system handles it for us if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - prefs.edit() + preferences.edit() .putString(PrefKeys.LANGUAGE, value) .apply() } @@ -83,7 +84,7 @@ class LocaleManager @Inject constructor( } } } else { - prefs.getNonNullString(PrefKeys.LANGUAGE, DEFAULT) + preferences.getNonNullString(PrefKeys.LANGUAGE, DEFAULT) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt index 105f99ced..010321855 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt @@ -18,7 +18,7 @@ package com.keylesspalace.tusky.util import android.util.Log import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat -import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.entity.AccountEntity import java.util.Locale private const val TAG: String = "LocaleUtils" diff --git a/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt b/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt deleted file mode 100644 index 969deba47..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* Copyright 2019 Joel Pyska - * - * 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.util - -import com.keylesspalace.tusky.entity.Notification -import org.json.JSONArray - -/** - * Serialize to string array and deserialize notifications type - */ - -fun serialize(data: Set?): String { - val array = JSONArray() - data?.forEach { - array.put(it.presentation) - } - return array.toString() -} - -fun deserialize(data: String?): Set { - val ret = HashSet() - data?.let { - val array = JSONArray(data) - for (i in 0 until array.length()) { - val item = array.getString(i) - val type = Notification.Type.byString(item) - if (type != Notification.Type.UNKNOWN) { - ret.add(type) - } - } - } - return ret -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/NumberUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/NumberUtils.kt index 29a2ec67c..541687582 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/NumberUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/NumberUtils.kt @@ -3,11 +3,11 @@ package com.keylesspalace.tusky.util import java.text.NumberFormat +import java.util.Locale import kotlin.math.abs import kotlin.math.ln import kotlin.math.pow -private val numberFormatter: NumberFormat = NumberFormat.getInstance() private val ln_1k = ln(1000.0) /** @@ -18,11 +18,17 @@ private val ln_1k = ln(1000.0) * a suffix appropriate to the scaling is appended. */ fun formatNumber(num: Long, min: Int = 100000): String { + val numberFormatter: NumberFormat = NumberFormat.getInstance() val absNum = abs(num) if (absNum < min) return numberFormatter.format(num) val exp = (ln(absNum.toDouble()) / ln_1k).toInt() // Suffixes here are locale-agnostic - return String.format("%.1f%c", num / 1000.0.pow(exp.toDouble()), "KMGTPE"[exp - 1]) + return String.format( + Locale.getDefault(), + "%.1f%c", + num / 1000.0.pow(exp.toDouble()), + "KMGTPE"[exp - 1] + ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt b/app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt deleted file mode 100644 index bdcf23925..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.keylesspalace.tusky.util - -import androidx.arch.core.util.Function - -/** - * This list implementation can help to keep two lists in sync - like real models and view models. - * - * Every operation on the main list triggers update of the supplementary list (but not vice versa). - * - * This makes sure that the main list is always the source of truth. - * - * Main list is projected to the supplementary list by the passed mapper function. - * - * Paired list is newer actually exposed and clients are provided with `getPairedCopy()`, - * `getPairedItem()` and `setPairedItem()`. This prevents modifications of the - * supplementary list size so lists are always have the same length. - * - * This implementation will not try to recover from exceptional cases so lists may be out of sync - * after the exception. - * - * It is most useful with immutable data because we cannot track changes inside stored objects. - * - * @param T type of elements in the main list - * @param V type of elements in supplementary list - * @param mapper Function, which will be used to translate items from the main list to the - * supplementary one. - * @constructor - */ -class PairedList(private val mapper: Function) : AbstractMutableList() { - private val main: MutableList = ArrayList() - private val synced: MutableList = ArrayList() - - val pairedCopy: List - get() = ArrayList(synced) - - fun getPairedItem(index: Int): V { - return synced[index] - } - - fun getPairedItemOrNull(index: Int): V? { - return synced.getOrNull(index) - } - - fun setPairedItem(index: Int, element: V) { - synced[index] = element - } - - override fun get(index: Int): T { - return main[index] - } - - override fun set(index: Int, element: T): T { - synced[index] = mapper.apply(element) - return main.set(index, element) - } - - override fun add(element: T): Boolean { - synced.add(mapper.apply(element)) - return main.add(element) - } - - override fun add(index: Int, element: T) { - synced.add(index, mapper.apply(element)) - main.add(index, element) - } - - override fun removeAt(index: Int): T { - synced.removeAt(index) - return main.removeAt(index) - } - - override val size: Int - get() = main.size -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/PickMediaFiles.kt b/app/src/main/java/com/keylesspalace/tusky/util/PickMediaFiles.kt index 4d3fcd5b4..ef3b7edbd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/PickMediaFiles.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/PickMediaFiles.kt @@ -16,19 +16,20 @@ package com.keylesspalace.tusky.util import android.app.Activity +import android.content.ClipData import android.content.Context import android.content.Intent import android.net.Uri import androidx.activity.result.contract.ActivityResultContract class PickMediaFiles : ActivityResultContract>() { - override fun createIntent(context: Context, allowMultiple: Boolean): Intent { + override fun createIntent(context: Context, input: Boolean): Intent { return Intent(Intent.ACTION_GET_CONTENT) .addCategory(Intent.CATEGORY_OPENABLE) .setType("*/*") .apply { putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*", "audio/*")) - putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple) + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, input) } } @@ -40,13 +41,17 @@ class PickMediaFiles : ActivityResultContract>() { // Single media, upload it and done. return listOf(intentData) } else if (clipData != null) { - val result: MutableList = mutableListOf() - for (i in 0 until clipData.itemCount) { - result.add(clipData.getItemAt(i).uri) - } - return result + return clipData.map { clipItem -> clipItem.uri } } } return emptyList() } } + +fun ClipData.map(transform: (ClipData.Item) -> T): List { + val destination = ArrayList(this.itemCount) + for (i in 0 until this.itemCount) { + destination.add(transform(getItemAt(i))) + } + return destination +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/RelativeTimeUpdater.kt b/app/src/main/java/com/keylesspalace/tusky/util/RelativeTimeUpdater.kt index a4dd85748..d065f1eb8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/RelativeTimeUpdater.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/RelativeTimeUpdater.kt @@ -2,11 +2,13 @@ package com.keylesspalace.tusky.util +import android.content.SharedPreferences import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope import androidx.lifecycle.repeatOnLifecycle -import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.settings.PrefKeys import kotlin.time.Duration.Companion.minutes import kotlinx.coroutines.delay @@ -19,18 +21,19 @@ private val UPDATE_INTERVAL = 1.minutes * if setting absoluteTimeView is false. * Start updates when the Fragment becomes visible and stop when it is hidden. */ -fun Fragment.updateRelativeTimePeriodically(callback: Runnable) { - val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) +fun Fragment.updateRelativeTimePeriodically(preferences: SharedPreferences, adapter: RecyclerView.Adapter) { val lifecycle = viewLifecycleOwner.lifecycle lifecycle.coroutineScope.launch { // This child coroutine will launch each time the Fragment moves to the STARTED state lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) - if (!useAbsoluteTime) { - while (true) { - callback.run() - delay(UPDATE_INTERVAL) - } + while (!useAbsoluteTime) { + adapter.notifyItemRangeChanged( + 0, + adapter.itemCount, + StatusBaseViewHolder.Key.KEY_CREATED + ) + delay(UPDATE_INTERVAL) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt index 504cb47f7..d92df6fe9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt @@ -27,21 +27,21 @@ import androidx.core.app.Person import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat -import androidx.core.graphics.drawable.toBitmap import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.GlideException import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.di.ApplicationScope +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class ShareShortcutHelper @Inject constructor( - private val context: Context, + @ApplicationContext private val context: Context, private val accountManager: AccountManager, @ApplicationScope private val externalScope: CoroutineScope ) { @@ -55,27 +55,24 @@ class ShareShortcutHelper @Inject constructor( val shortcuts = accountManager.accounts.take(maxNumberOfShortcuts).mapNotNull { account -> - val bmp = try { + val drawable = try { Glide.with(context) - .asBitmap() + .asDrawable() .load(account.profilePictureUrl) .submitAsync(innerSize, innerSize) } catch (e: GlideException) { // https://github.com/bumptech/glide/issues/4672 :/ Log.w(TAG, "failed to load avatar ${account.profilePictureUrl}", e) - AppCompatResources.getDrawable(context, R.drawable.avatar_default)?.toBitmap(innerSize, innerSize) ?: return@mapNotNull null + AppCompatResources.getDrawable(context, R.drawable.avatar_default) ?: return@mapNotNull null } // inset the loaded bitmap inside a 108dp transparent canvas so it looks good as adaptive icon val outBmp = Bitmap.createBitmap(outerSize, outerSize, Bitmap.Config.ARGB_8888) val canvas = Canvas(outBmp) - canvas.drawBitmap( - bmp, - (outerSize - innerSize).toFloat() / 2f, - (outerSize - innerSize).toFloat() / 2f, - null - ) + val borderSize = (outerSize - innerSize) / 2 + drawable.setBounds(borderSize, borderSize, borderSize + innerSize, borderSize + innerSize) + drawable.draw(canvas) val icon = IconCompat.createWithAdaptiveBitmap(outBmp) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Single.kt b/app/src/main/java/com/keylesspalace/tusky/util/Single.kt deleted file mode 100644 index d77bdb5a7..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/Single.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.keylesspalace.tusky.util - -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import at.connyduck.calladapter.networkresult.NetworkResult -import at.connyduck.calladapter.networkresult.fold -import java.util.function.Consumer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch - -/** - * Simple reimplementation of RxJava's Single using a Kotlin coroutine, - * intended to be consumed by legacy Java code only. - */ -class Single(private val producer: suspend CoroutineScope.() -> NetworkResult) { - fun subscribe( - owner: LifecycleOwner, - onSuccess: Consumer, - onError: Consumer - ): Job { - return owner.lifecycleScope.launch { - producer().fold( - onSuccess = { onSuccess.accept(it) }, - onFailure = { onError.accept(it) } - ) - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt index faa671a3c..4da9b16d4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt @@ -9,105 +9,99 @@ import android.text.style.DynamicDrawableSpan import android.text.style.ForegroundColorSpan import android.text.style.ImageSpan import android.text.style.URLSpan +import com.keylesspalace.tusky.util.twittertext.Regex import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import java.util.regex.Pattern -import kotlin.math.max /** * @see * Tag#HASHTAG_RE. */ -private const val HASHTAG_SEPARATORS = "_\\u00B7\\u200c" -private const val UNICODE_WORD = "\\p{L}\\p{Mn}\\p{Nd}\\p{Nl}\\p{Pc}" // Ugh, java ( https://stackoverflow.com/questions/4304928/unicode-equivalents-for-w-and-b-in-java-regular-expressions ) -private const val TAG_REGEX = "(?:^|[^/)\\w])#(([${UNICODE_WORD}_][$UNICODE_WORD$HASHTAG_SEPARATORS]*[\\p{Alpha}$HASHTAG_SEPARATORS][$UNICODE_WORD$HASHTAG_SEPARATORS]*[${UNICODE_WORD}_])|([${UNICODE_WORD}_]*[\\p{Alpha}][${UNICODE_WORD}_]*))" +private const val HASHTAG_SEPARATORS = "_\\u00B7\\u30FB\\u200c" +internal const val TAG_PATTERN_STRING = "(? * Account#MENTION_RE */ -private const val USERNAME_REGEX = "[\\w]+([\\w\\.-]+[\\w]+)?" -private const val MENTION_REGEX = "(?<=^|[^\\/$UNICODE_WORD])@(($USERNAME_REGEX)(?:@[$UNICODE_WORD\\.\\-]+[$UNICODE_WORD]+)?)" +private const val USERNAME_PATTERN_STRING = "[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?" +internal const val MENTION_PATTERN_STRING = "(? Boolean -) { - val pattern: Pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE) -} +class PatternFinder( + val searchString: String, + val type: FoundMatchType, + val pattern: Pattern +) /** * Takes text containing mentions and hashtags and urls and makes them the given colour. + * @param finders The finders to use. This is here so they can be overridden from unit tests. */ -fun highlightSpans(text: Spannable, colour: Int) { +fun Spannable.highlightSpans(colour: Int, finders: List = defaultFinders) { // Strip all existing colour spans. for (spanClass in spanClasses) { - clearSpans(text, spanClass) + clearSpans(spanClass) } - // Colour the mentions and hashtags. - val string = text.toString() - val length = text.length - var start = 0 - var end = 0 - while (end in 0 until length && start >= 0) { - // Search for url first because it can contain the other characters - val found = findPattern(string, end) - start = found.start - end = found.end - if (start in 0 until end) { - text.setSpan( - getSpan(found.matchType, string, colour, start, end), - start, - end, - Spanned.SPAN_INCLUSIVE_EXCLUSIVE - ) - start += finders[found.matchType]!!.searchPrefixWidth + for (finder in finders) { + // before running the regular expression, check if there is even a chance of it finding something + if (this.contains(finder.searchString, ignoreCase = true)) { + val matcher = finder.pattern.matcher(this) + + while (matcher.find()) { + // we found a match + val start = matcher.start(1) + + val end = matcher.end(1) + + // only add a span if there is no other one yet (e.g. the #anchor part of an url might match as hashtag, but must be ignored) + if (this.getSpans(start, end, URLSpan::class.java).isEmpty()) { + this.setSpan( + getSpan(finder.type, this, colour, start, end), + start, + end, + Spanned.SPAN_INCLUSIVE_EXCLUSIVE + ) + } + } } } } +private fun Spannable.clearSpans(spanClass: Class) { + for (span in getSpans(0, length, spanClass)) { + removeSpan(span) + } +} + /** * Replaces text of the form [iconics name] with their spanned counterparts (ImageSpan). */ fun addDrawables(text: CharSequence, color: Int, size: Int, context: Context): Spannable { val builder = SpannableStringBuilder(text) - val pattern = Pattern.compile("\\[iconics ([0-9a-z_]+)\\]") + val pattern = Pattern.compile("\\[iconics ([0-9a-z_]+)]") val matcher = pattern.matcher(builder) while (matcher.find()) { val resourceName = matcher.group(1) @@ -123,98 +117,16 @@ fun addDrawables(text: CharSequence, color: Int, size: Int, context: Context): S return builder } -private fun clearSpans(text: Spannable, spanClass: Class) { - for (span in text.getSpans(0, text.length, spanClass)) { - text.removeSpan(span) - } -} - -private fun findPattern(string: String, fromIndex: Int): FindCharsResult { - val result = FindCharsResult() - for (i in fromIndex..string.lastIndex) { - val c = string[i] - for (matchType in FoundMatchType.entries) { - val finder = finders[matchType] - if (finder!!.searchCharacter == c && - ( - (i - fromIndex) < finder.searchPrefixWidth || - finder.prefixValidator(string.codePointAt(i - finder.searchPrefixWidth)) - ) - ) { - result.matchType = matchType - result.start = max(0, i - finder.searchPrefixWidth) - findEndOfPattern(string, result, finder.pattern) - if (result.start + finder.searchPrefixWidth <= i + 1 && // The found result is actually triggered by the correct search character - result.end >= result.start - ) { // ...and we actually found a valid result - return result - } - } - } - } - return result -} - -private fun findEndOfPattern(string: String, result: FindCharsResult, pattern: Pattern) { - val matcher = pattern.matcher(string) - if (matcher.find(result.start)) { - // Once we have API level 26+, we can use named captures... - val end = matcher.end() - result.start = matcher.start() - when (result.matchType) { - FoundMatchType.TAG -> { - if (isValidForTagPrefix(string.codePointAt(result.start))) { - if (string[result.start] != '#' || - (string[result.start] == '#' && string[result.start + 1] == '#') - ) { - ++result.start - } - } - } - else -> { - if (Character.isWhitespace(string.codePointAt(result.start))) { - ++result.start - } - } - } - when (result.matchType) { - FoundMatchType.HTTP_URL, FoundMatchType.HTTPS_URL -> { - // Preliminary url patterns are fast/permissive, now we'll do full validation - if (STRICT_WEB_URL_PATTERN.matcher(string.substring(result.start, end)).matches()) { - result.end = end - } - } - else -> result.end = end - } - } -} - private fun getSpan( matchType: FoundMatchType, - string: String, + string: CharSequence, colour: Int, start: Int, end: Int ): CharacterStyle { return when (matchType) { - FoundMatchType.HTTP_URL -> NoUnderlineURLSpan(string.substring(start, end)) - FoundMatchType.HTTPS_URL -> NoUnderlineURLSpan(string.substring(start, end)) + FoundMatchType.HTTP_URL, FoundMatchType.HTTPS_URL -> NoUnderlineURLSpan(string.substring(start, end)) FoundMatchType.MENTION -> MentionSpan(string.substring(start, end)) else -> ForegroundColorSpan(colour) } } - -private fun isWordCharacters(codePoint: Int): Boolean { - return (codePoint in 0x30..0x39) || // [0-9] - (codePoint in 0x41..0x5a) || // [A-Z] - (codePoint == 0x5f) || // _ - (codePoint in 0x61..0x7a) // [a-z] -} - -private fun isValidForTagPrefix(codePoint: Int): Boolean { - return !( - isWordCharacters(codePoint) || // \w - (codePoint == 0x2f) || // / - (codePoint == 0x29) - ) // ) -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt index e55e1706d..3980ac712 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt @@ -18,7 +18,7 @@ package com.keylesspalace.tusky.util import android.content.SharedPreferences -import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.settings.PrefKeys data class StatusDisplayOptions( diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt index 56d60e954..86c8be617 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt @@ -34,6 +34,7 @@ fun String.parseAsMastodonHtml(tagHandler: TagHandler? = tuskyTagHandler): Spann return this.replace("
", "
 ") .replace("
", "
 ") .replace("
", "
 ") + .replace("
\n", "
") // pixelfed quirk https://github.com/tuskyapp/Tusky/issues/4663 .replace("\n", "
") .replace(" ", "  ") .parseAsHtml(tagHandler = tagHandler) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt index db56aea4f..6210bba76 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt @@ -115,7 +115,7 @@ class StatusViewHelper(private val itemView: View) { .into(mediaPreviews[i]) } else { val placeholder = if (attachment.blurhash != null) { - decodeBlurHash(context, attachment.blurhash) + BlurhashDrawable(context, attachment.blurhash) } else { mediaPreviewUnloaded } @@ -143,8 +143,8 @@ class StatusViewHelper(private val itemView: View) { } else { mediaPreviews[i].removeFocalPoint() if (statusDisplayOptions.useBlurhash && attachment.blurhash != null) { - val blurhashBitmap = decodeBlurHash(context, attachment.blurhash) - mediaPreviews[i].setImageDrawable(blurhashBitmap) + val blurhashDrawable = BlurhashDrawable(context, attachment.blurhash) + mediaPreviews[i].setImageDrawable(blurhashDrawable) } else { mediaPreviews[i].setImageDrawable(mediaPreviewUnloaded) } @@ -237,13 +237,13 @@ class StatusViewHelper(private val itemView: View) { var labelText = getLabelTypeText(context, attachments[0].type) if (sensitive) { val sensitiveText = context.getString(R.string.post_sensitive_media_title) - labelText += String.format(" (%s)", sensitiveText) + labelText += " ($sensitiveText)" } mediaLabel.text = labelText // Set the icon next to the label. val drawableId = getLabelIcon(attachments[0].type) - mediaLabel.setCompoundDrawablesWithIntrinsicBounds(drawableId, 0, 0, 0) + mediaLabel.setCompoundDrawablesRelativeWithIntrinsicBounds(drawableId, 0, 0, 0) mediaLabel.setOnClickListener { listener.onViewMedia(null, 0) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt index 3a9388d65..01aff3e00 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt @@ -3,10 +3,14 @@ package com.keylesspalace.tusky.util import android.text.Spanned +import java.util.regex.Pattern import kotlin.random.Random private const val POSSIBLE_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" +const val HASHTAG_EXPRESSION = "([\\w_]*[\\p{Alpha}_][\\w_]*)" +val hashtagPattern = Pattern.compile(HASHTAG_EXPRESSION, Pattern.CASE_INSENSITIVE or Pattern.MULTILINE) + fun randomAlphanumericString(count: Int): String { val chars = CharArray(count) for (i in 0 until count) { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt index fc7df19df..08aa992c7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt @@ -19,7 +19,6 @@ package com.keylesspalace.tusky.util import android.content.Context import android.content.res.Configuration import android.graphics.Color -import android.graphics.PorterDuff import android.graphics.drawable.Drawable import androidx.annotation.AttrRes import androidx.appcompat.app.AppCompatDelegate @@ -39,10 +38,7 @@ fun getDimension(context: Context, @AttrRes attribute: Int): Int { } fun setDrawableTint(context: Context, drawable: Drawable, @AttrRes attribute: Int) { - drawable.setColorFilter( - MaterialColors.getColor(context, attribute, Color.BLACK), - PorterDuff.Mode.SRC_IN - ) + drawable.setTint(MaterialColors.getColor(context, attribute, Color.BLACK)) } fun setAppNightMode(flavor: String?) { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt index 4ea3edeab..5cdcd8977 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt @@ -20,7 +20,7 @@ inline fun AppCompatActivity.viewBinding( bindingInflater(layoutInflater) } -private class ViewLifecycleLazy( +private class ViewLifecycleLazy( private val fragment: Fragment, private val initializer: (View) -> T ) : Lazy, LifecycleEventObserver { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt index 0975b00f1..7c0f8adf2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -36,10 +36,8 @@ package com.keylesspalace.tusky.util import androidx.paging.CombinedLoadStates import androidx.paging.LoadState -import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.TrendingTag -import com.keylesspalace.tusky.viewdata.NotificationViewData import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData import com.keylesspalace.tusky.viewdata.TrendingViewData @@ -61,21 +59,6 @@ fun Status.toViewData( ) } -@JvmName("notificationToViewData") -fun Notification.toViewData( - isShowingContent: Boolean, - isExpanded: Boolean, - isCollapsed: Boolean -): NotificationViewData.Concrete { - return NotificationViewData.Concrete( - this.type, - this.id, - this.account, - this.status?.toViewData(isShowingContent, isExpanded, isCollapsed), - this.report - ) -} - fun List.toViewData(): List { val maxTrendingValue = flatMap { tag -> tag.history } .mapNotNull { it.uses.toLongOrNull() } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt index 392f65b77..e2d95c2b1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt @@ -16,11 +16,23 @@ package com.keylesspalace.tusky.util +import android.os.Build import android.util.Log import android.view.View +import android.view.ViewGroup import android.widget.TextView +import androidx.core.graphics.Insets +import androidx.core.view.OnApplyWindowInsetsListener +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsAnimationCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsCompat.Type.systemBars +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.keylesspalace.tusky.R fun View.show() { this.visibility = View.VISIBLE @@ -78,3 +90,100 @@ fun TextView.fixTextSelection() { setTextIsSelectable(false) post { setTextIsSelectable(true) } } + +/** + * Makes sure the [RecyclerView] has the correct bottom padding set + * and no system bars or floating action buttons cover the content when it is scrolled all the way up. + * This must be called before window insets are applied (Activity.onCreate or Fragment.onViewCreated). + * The RecyclerView needs to have clipToPadding set to false for this to work. + * @param fab true if there is a [FloatingActionButton] above the RecyclerView + */ +fun RecyclerView.ensureBottomPadding(fab: Boolean = false) { + val bottomPadding = if (fab) { + context.resources.getDimensionPixelSize(R.dimen.recyclerview_bottom_padding_actionbutton) + } else { + context.resources.getDimensionPixelSize(R.dimen.recyclerview_bottom_padding_no_actionbutton) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets -> + val systemBarsInsets = insets.getInsets(systemBars()) + view.updatePadding(bottom = bottomPadding + systemBarsInsets.bottom) + WindowInsetsCompat.Builder(insets) + .setInsets(systemBars(), Insets.of(systemBarsInsets.left, systemBarsInsets.top, systemBarsInsets.right, 0)) + .build() + } + } else { + updatePadding(bottom = bottomPadding) + } +} + +/** Makes sure a [FloatingActionButton] has the correct bottom margin set + * so it is not covered by any system bars. + */ +fun FloatingActionButton.ensureBottomMargin() { + ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets -> + val bottomInsets = insets.getInsets(systemBars()).bottom + val actionButtonMargin = resources.getDimensionPixelSize(R.dimen.fabMargin) + view.updateLayoutParams { + bottomMargin = bottomInsets + actionButtonMargin + } + insets + } +} + +/** + * Combines WindowInsetsAnimationCompat.Callback and OnApplyWindowInsetsListener + * for easy implementation of layouts that animate with they keyboard. + * The animation callback is only called when something animates, so it isn't suitable for initial setup. + * The OnApplyWindowInsetsListener can do that, but the insets it supplies must not be used when an animation is ongoing, + * as that messes with the animation. + */ +fun View.setOnWindowInsetsChangeListener(listener: (WindowInsetsCompat) -> Unit) { + val callback = WindowInsetsCallback(listener) + + ViewCompat.setWindowInsetsAnimationCallback(this, callback) + ViewCompat.setOnApplyWindowInsetsListener(this, callback) +} + +private class WindowInsetsCallback( + private val listener: (WindowInsetsCompat) -> Unit, +) : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP), + OnApplyWindowInsetsListener { + + var animationRunning = false + var deferredInsets: WindowInsetsCompat? = null + + override fun onPrepare(animation: WindowInsetsAnimationCompat) { + animationRunning = true + } + + override fun onProgress( + insets: WindowInsetsCompat, + runningAnimations: List, + ): WindowInsetsCompat { + listener(insets) + return WindowInsetsCompat.CONSUMED + } + + override fun onApplyWindowInsets( + view: View, + insets: WindowInsetsCompat, + ): WindowInsetsCompat { + if (!animationRunning) { + listener(insets) + deferredInsets = null + } else { + deferredInsets = insets + } + return WindowInsetsCompat.CONSUMED + } + + override fun onEnd(animation: WindowInsetsAnimationCompat) { + deferredInsets?.let { insets -> + listener(insets) + deferredInsets = null + } + animationRunning = false + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/twittertext/Regex.java b/app/src/main/java/com/keylesspalace/tusky/util/twittertext/Regex.java new file mode 100644 index 000000000..c7b588fd5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/twittertext/Regex.java @@ -0,0 +1,348 @@ +// Copyright 2018 Twitter, Inc. +// Licensed under the Apache License, Version 2.0 +// http://www.apache.org/licenses/LICENSE-2.0 + +// Tusky changed: slight adaptions for Mastodon compatibility + +package com.keylesspalace.tusky.util.twittertext; + +import java.util.Collection; +import java.util.Iterator; +import java.util.regex.Pattern; +import javax.annotation.Nonnull; + +public class Regex { + + protected Regex() { + } + + private static final String URL_VALID_GTLD = + "(?:(?:" + + join(TldLists.GTLDS) + + ")(?=[^a-z0-9@+-]|$))"; + private static final String URL_VALID_CCTLD = + "(?:(?:" + + join(TldLists.CTLDS) + + ")(?=[^a-z0-9@+-]|$))"; + + private static final String INVALID_CHARACTERS = + "\\uFFFE" + // BOM + "\\uFEFF" + // BOM + "\\uFFFF"; // Special + + private static final String DIRECTIONAL_CHARACTERS = + "\\u061C" + // ARABIC LETTER MARK (ALM) + "\\u200E" + // LEFT-TO-RIGHT MARK (LRM) + "\\u200F" + // RIGHT-TO-LEFT MARK (RLM) + "\\u202A" + // LEFT-TO-RIGHT EMBEDDING (LRE) + "\\u202B" + // RIGHT-TO-LEFT EMBEDDING (RLE) + "\\u202C" + // POP DIRECTIONAL FORMATTING (PDF) + "\\u202D" + // LEFT-TO-RIGHT OVERRIDE (LRO) + "\\u202E" + // RIGHT-TO-LEFT OVERRIDE (RLO) + "\\u2066" + // LEFT-TO-RIGHT ISOLATE (LRI) + "\\u2067" + // RIGHT-TO-LEFT ISOLATE (RLI) + "\\u2068" + // FIRST STRONG ISOLATE (FSI) + "\\u2069"; // POP DIRECTIONAL ISOLATE (PDI) + + + private static final String UNICODE_SPACES = "[" + + "\\u0009-\\u000d" + // # White_Space # Cc [5] .. + "\\u0020" + // White_Space # Zs SPACE + "\\u0085" + // White_Space # Cc + "\\u00a0" + // White_Space # Zs NO-BREAK SPACE + "\\u1680" + // White_Space # Zs OGHAM SPACE MARK + "\\u180E" + // White_Space # Zs MONGOLIAN VOWEL SEPARATOR + "\\u2000-\\u200a" + // # White_Space # Zs [11] EN QUAD..HAIR SPACE + "\\u2028" + // White_Space # Zl LINE SEPARATOR + "\\u2029" + // White_Space # Zp PARAGRAPH SEPARATOR + "\\u202F" + // White_Space # Zs NARROW NO-BREAK SPACE + "\\u205F" + // White_Space # Zs MEDIUM MATHEMATICAL SPACE + "\\u3000" + // White_Space # Zs IDEOGRAPHIC SPACE + "]"; + + private static final String LATIN_ACCENTS_CHARS = + // Latin-1 + "\\u00c0-\\u00d6\\u00d8-\\u00f6\\u00f8-\\u00ff" + + // Latin Extended A and B + "\\u0100-\\u024f" + + // IPA Extensions + "\\u0253\\u0254\\u0256\\u0257\\u0259\\u025b\\u0263\\u0268\\u026f\\u0272\\u0289\\u028b" + + // Hawaiian + "\\u02bb" + + // Combining diacritics + "\\u0300-\\u036f" + + // Latin Extended Additional (mostly for Vietnamese) + "\\u1e00-\\u1eff"; + + private static final String CYRILLIC_CHARS = "\\u0400-\\u04ff"; + + // Generated from unicode_regex/unicode_regex_groups.scala, more inclusive than Java's \p{L}\p{M} + private static final String HASHTAG_LETTERS_AND_MARKS = "\\p{L}\\p{M}" + + "\\u037f\\u0528-\\u052f\\u08a0-\\u08b2\\u08e4-\\u08ff\\u0978\\u0980\\u0c00\\u0c34\\u0c81" + + "\\u0d01\\u0ede\\u0edf\\u10c7\\u10cd\\u10fd-\\u10ff\\u16f1-\\u16f8\\u17b4\\u17b5\\u191d" + + "\\u191e\\u1ab0-\\u1abe\\u1bab-\\u1bad\\u1bba-\\u1bbf\\u1cf3-\\u1cf6\\u1cf8\\u1cf9" + + "\\u1de7-\\u1df5\\u2cf2\\u2cf3\\u2d27\\u2d2d\\u2d66\\u2d67\\u9fcc\\ua674-\\ua67b\\ua698" + + "-\\ua69d\\ua69f\\ua792-\\ua79f\\ua7aa-\\ua7ad\\ua7b0\\ua7b1\\ua7f7-\\ua7f9\\ua9e0-" + + "\\ua9ef\\ua9fa-\\ua9fe\\uaa7c-\\uaa7f\\uaae0-\\uaaef\\uaaf2-\\uaaf6\\uab30-\\uab5a" + + "\\uab5c-\\uab5f\\uab64\\uab65\\uf870-\\uf87f\\uf882\\uf884-\\uf89f\\uf8b8\\uf8c1-" + + "\\uf8d6\\ufa2e\\ufa2f\\ufe27-\\ufe2d\\ud800\\udee0\\ud800\\udf1f\\ud800\\udf50-\\ud800" + + "\\udf7a\\ud801\\udd00-\\ud801\\udd27\\ud801\\udd30-\\ud801\\udd63\\ud801\\ude00-\\ud801" + + "\\udf36\\ud801\\udf40-\\ud801\\udf55\\ud801\\udf60-\\ud801\\udf67\\ud802\\udc60-\\ud802" + + "\\udc76\\ud802\\udc80-\\ud802\\udc9e\\ud802\\udd80-\\ud802\\uddb7\\ud802\\uddbe\\ud802" + + "\\uddbf\\ud802\\ude80-\\ud802\\ude9c\\ud802\\udec0-\\ud802\\udec7\\ud802\\udec9-\\ud802" + + "\\udee6\\ud802\\udf80-\\ud802\\udf91\\ud804\\udc7f\\ud804\\udcd0-\\ud804\\udce8\\ud804" + + "\\udd00-\\ud804\\udd34\\ud804\\udd50-\\ud804\\udd73\\ud804\\udd76\\ud804\\udd80-\\ud804" + + "\\uddc4\\ud804\\uddda\\ud804\\ude00-\\ud804\\ude11\\ud804\\ude13-\\ud804\\ude37\\ud804" + + "\\udeb0-\\ud804\\udeea\\ud804\\udf01-\\ud804\\udf03\\ud804\\udf05-\\ud804\\udf0c\\ud804" + + "\\udf0f\\ud804\\udf10\\ud804\\udf13-\\ud804\\udf28\\ud804\\udf2a-\\ud804\\udf30\\ud804" + + "\\udf32\\ud804\\udf33\\ud804\\udf35-\\ud804\\udf39\\ud804\\udf3c-\\ud804\\udf44\\ud804" + + "\\udf47\\ud804\\udf48\\ud804\\udf4b-\\ud804\\udf4d\\ud804\\udf57\\ud804\\udf5d-\\ud804" + + "\\udf63\\ud804\\udf66-\\ud804\\udf6c\\ud804\\udf70-\\ud804\\udf74\\ud805\\udc80-\\ud805" + + "\\udcc5\\ud805\\udcc7\\ud805\\udd80-\\ud805\\uddb5\\ud805\\uddb8-\\ud805\\uddc0\\ud805" + + "\\ude00-\\ud805\\ude40\\ud805\\ude44\\ud805\\ude80-\\ud805\\udeb7\\ud806\\udca0-\\ud806" + + "\\udcdf\\ud806\\udcff\\ud806\\udec0-\\ud806\\udef8\\ud808\\udf6f-\\ud808\\udf98\\ud81a" + + "\\ude40-\\ud81a\\ude5e\\ud81a\\uded0-\\ud81a\\udeed\\ud81a\\udef0-\\ud81a\\udef4\\ud81a" + + "\\udf00-\\ud81a\\udf36\\ud81a\\udf40-\\ud81a\\udf43\\ud81a\\udf63-\\ud81a\\udf77\\ud81a" + + "\\udf7d-\\ud81a\\udf8f\\ud81b\\udf00-\\ud81b\\udf44\\ud81b\\udf50-\\ud81b\\udf7e\\ud81b" + + "\\udf8f-\\ud81b\\udf9f\\ud82f\\udc00-\\ud82f\\udc6a\\ud82f\\udc70-\\ud82f\\udc7c\\ud82f" + + "\\udc80-\\ud82f\\udc88\\ud82f\\udc90-\\ud82f\\udc99\\ud82f\\udc9d\\ud82f\\udc9e\\ud83a" + + "\\udc00-\\ud83a\\udcc4\\ud83a\\udcd0-\\ud83a\\udcd6\\ud83b\\ude00-\\ud83b\\ude03\\ud83b" + + "\\ude05-\\ud83b\\ude1f\\ud83b\\ude21\\ud83b\\ude22\\ud83b\\ude24\\ud83b\\ude27\\ud83b" + + "\\ude29-\\ud83b\\ude32\\ud83b\\ude34-\\ud83b\\ude37\\ud83b\\ude39\\ud83b\\ude3b\\ud83b" + + "\\ude42\\ud83b\\ude47\\ud83b\\ude49\\ud83b\\ude4b\\ud83b\\ude4d-\\ud83b\\ude4f\\ud83b" + + "\\ude51\\ud83b\\ude52\\ud83b\\ude54\\ud83b\\ude57\\ud83b\\ude59\\ud83b\\ude5b\\ud83b" + + "\\ude5d\\ud83b\\ude5f\\ud83b\\ude61\\ud83b\\ude62\\ud83b\\ude64\\ud83b\\ude67-\\ud83b" + + "\\ude6a\\ud83b\\ude6c-\\ud83b\\ude72\\ud83b\\ude74-\\ud83b\\ude77\\ud83b\\ude79-\\ud83b" + + "\\ude7c\\ud83b\\ude7e\\ud83b\\ude80-\\ud83b\\ude89\\ud83b\\ude8b-\\ud83b\\ude9b\\ud83b" + + "\\udea1-\\ud83b\\udea3\\ud83b\\udea5-\\ud83b\\udea9\\ud83b\\udeab-\\ud83b\\udebb"; + + // Generated from unicode_regex/unicode_regex_groups.scala, more inclusive than Java's \p{Nd} + private static final String HASHTAG_NUMERALS = "\\p{Nd}" + + "\\u0de6-\\u0def\\ua9f0-\\ua9f9\\ud804\\udcf0-\\ud804\\udcf9\\ud804\\udd36-\\ud804" + + "\\udd3f\\ud804\\uddd0-\\ud804\\uddd9\\ud804\\udef0-\\ud804\\udef9\\ud805\\udcd0-\\ud805" + + "\\udcd9\\ud805\\ude50-\\ud805\\ude59\\ud805\\udec0-\\ud805\\udec9\\ud806\\udce0-\\ud806" + + "\\udce9\\ud81a\\ude60-\\ud81a\\ude69\\ud81a\\udf50-\\ud81a\\udf59"; + + private static final String HASHTAG_SPECIAL_CHARS = "_" + //underscore + "\\u200c" + // ZERO WIDTH NON-JOINER (ZWNJ) + "\\u200d" + // ZERO WIDTH JOINER (ZWJ) + "\\ua67e" + // CYRILLIC KAVYKA + "\\u05be" + // HEBREW PUNCTUATION MAQAF + "\\u05f3" + // HEBREW PUNCTUATION GERESH + "\\u05f4" + // HEBREW PUNCTUATION GERSHAYIM + "\\uff5e" + // FULLWIDTH TILDE + "\\u301c" + // WAVE DASH + "\\u309b" + // KATAKANA-HIRAGANA VOICED SOUND MARK + "\\u309c" + // KATAKANA-HIRAGANA SEMI-VOICED SOUND MARK + "\\u30a0" + // KATAKANA-HIRAGANA DOUBLE HYPHEN + "\\u30fb" + // KATAKANA MIDDLE DOT + "\\u3003" + // DITTO MARK + "\\u0f0b" + // TIBETAN MARK INTERSYLLABIC TSHEG + "\\u0f0c" + // TIBETAN MARK DELIMITER TSHEG BSTAR + "\\u00b7"; // MIDDLE DOT + + private static final String HASHTAG_LETTERS_NUMERALS = + HASHTAG_LETTERS_AND_MARKS + HASHTAG_NUMERALS + HASHTAG_SPECIAL_CHARS; + private static final String HASHTAG_LETTERS_SET = "[" + HASHTAG_LETTERS_AND_MARKS + "]"; + private static final String HASHTAG_LETTERS_NUMERALS_SET = "[" + HASHTAG_LETTERS_NUMERALS + "]"; + + /* URL related hash regex collection */ + private static final String URL_VALID_PRECEDING_CHARS = + "(?:[^a-z0-9@@$##" + INVALID_CHARACTERS + "]|[" + DIRECTIONAL_CHARACTERS + "]|^)"; + + private static final String URL_VALID_CHARS = "[a-z0-9" + LATIN_ACCENTS_CHARS + "]"; + private static final String URL_VALID_SUBDOMAIN = + "(?>(?:" + URL_VALID_CHARS + "[" + URL_VALID_CHARS + "\\-_]*)?" + URL_VALID_CHARS + "\\.)"; + private static final String URL_VALID_DOMAIN_NAME = + "(?:(?:" + URL_VALID_CHARS + "[" + URL_VALID_CHARS + "\\-]*)?" + URL_VALID_CHARS + "\\.)"; + + private static final String PUNCTUATION_CHARS = "-_!\"#$%&'\\(\\)*+,./:;<=>?@\\[\\]^`\\{|}~"; + + // Any non-space, non-punctuation characters. + // \p{Z} = any kind of whitespace or invisible separator. + private static final String URL_VALID_UNICODE_CHARS = + "[^" + PUNCTUATION_CHARS + "\\s\\p{Z}\\p{InGeneralPunctuation}]"; + private static final String URL_VALID_UNICODE_DOMAIN_NAME = + "(?:(?:" + URL_VALID_UNICODE_CHARS + "[" + URL_VALID_UNICODE_CHARS + "\\-]*)?" + + URL_VALID_UNICODE_CHARS + "\\.)"; + + private static final String URL_PUNYCODE = "(?:xn--[-0-9a-z]+)"; + + private static final String URL_VALID_DOMAIN = + "(?:" + // optional sub-domain + domain + TLD + URL_VALID_SUBDOMAIN + "*" + URL_VALID_DOMAIN_NAME + // e.g. twitter.com, foo.co.jp ... + "(?:" + URL_VALID_GTLD + "|" + URL_VALID_CCTLD + "|" + URL_PUNYCODE + ")" + + ")" + + "|(?:" + "(?<=https?://)" + + "(?:" + + "(?:" + URL_VALID_DOMAIN_NAME + URL_VALID_CCTLD + ")" + // protocol + domain + ccTLD + "|(?:" + + URL_VALID_UNICODE_DOMAIN_NAME + // protocol + unicode domain + TLD + "(?:" + URL_VALID_GTLD + "|" + URL_VALID_CCTLD + ")" + + ")" + + ")" + + ")" + + "|(?:" + // domain + ccTLD + '/' + URL_VALID_DOMAIN_NAME + URL_VALID_CCTLD + "(?=/)" + // e.g. t.co/ + ")"; + + private static final String URL_VALID_PORT_NUMBER = "[0-9]++"; + + private static final String URL_VALID_GENERAL_PATH_CHARS = + "[a-z0-9!\\*';:=\\+,.\\$/%#\\[\\]\\-\\u2013_~\\|&@" + + LATIN_ACCENTS_CHARS + CYRILLIC_CHARS + "]"; + + /** + * Allow URL paths to contain up to two nested levels of balanced parens + * 1. Used in Wikipedia URLs like /Primer_(film) + * 2. Used in IIS sessions like /S(dfd346)/ + * 3. Used in Rdio URLs like /track/We_Up_(Album_Version_(Edited))/ + */ + private static final String URL_BALANCED_PARENS = "\\(" + + "(?:" + + URL_VALID_GENERAL_PATH_CHARS + "+" + + "|" + + // allow one nested level of balanced parentheses + "(?:" + + URL_VALID_GENERAL_PATH_CHARS + "*" + + "\\(" + + URL_VALID_GENERAL_PATH_CHARS + "+" + + "\\)" + + URL_VALID_GENERAL_PATH_CHARS + "*" + + ")" + + ")" + + "\\)"; + + /** + * Valid end-of-path characters (so /foo. does not gobble the period). + * 2. Allow =&# for empty URL parameters and other URL-join artifacts + */ + private static final String URL_VALID_PATH_ENDING_CHARS = + "[a-z0-9=_#/\\-\\+" + LATIN_ACCENTS_CHARS + CYRILLIC_CHARS + "]|(?:" + + URL_BALANCED_PARENS + ")"; + + private static final String URL_VALID_PATH = "(?:" + + "(?:" + + URL_VALID_GENERAL_PATH_CHARS + "*" + + "(?:" + URL_BALANCED_PARENS + URL_VALID_GENERAL_PATH_CHARS + "*)*" + + URL_VALID_PATH_ENDING_CHARS + + ")|(?:@" + URL_VALID_GENERAL_PATH_CHARS + "+/)" + + ")"; + + private static final String URL_VALID_URL_QUERY_CHARS = + "[a-z0-9!?\\*'\\(\\);:&=\\+\\$/%#\\[\\]\\-_\\.,~\\|@]"; + private static final String URL_VALID_URL_QUERY_ENDING_CHARS = "[a-z0-9\\-_&=#/]"; + public static final String VALID_URL_PATTERN_STRING = + URL_VALID_PRECEDING_CHARS + + "(" + + "https?://" + + "(" + URL_VALID_DOMAIN + ")" + + "(?::(" + URL_VALID_PORT_NUMBER + "))?" + + "(/" + + URL_VALID_PATH + "*+" + + ")?" + + "(\\?" + URL_VALID_URL_QUERY_CHARS + "*" + + URL_VALID_URL_QUERY_ENDING_CHARS + ")?" + + ")"; + + private static final String AT_SIGNS_CHARS = "@\uFF20"; + private static final String DOLLAR_SIGN_CHAR = "\\$"; + private static final String CASHTAG = "[a-z]{1,6}(?:[._][a-z]{1,2})?"; + + /* Begin public constants */ + + public static final Pattern INVALID_CHARACTERS_PATTERN; + public static final Pattern VALID_HASHTAG; + public static final int VALID_HASHTAG_GROUP_BEFORE = 1; + public static final int VALID_HASHTAG_GROUP_HASH = 2; + public static final int VALID_HASHTAG_GROUP_TAG = 3; + public static final Pattern INVALID_HASHTAG_MATCH_END; + public static final Pattern RTL_CHARACTERS; + + public static final Pattern AT_SIGNS; + public static final Pattern VALID_MENTION_OR_LIST; + public static final int VALID_MENTION_OR_LIST_GROUP_BEFORE = 1; + public static final int VALID_MENTION_OR_LIST_GROUP_AT = 2; + public static final int VALID_MENTION_OR_LIST_GROUP_USERNAME = 3; + public static final int VALID_MENTION_OR_LIST_GROUP_LIST = 4; + + public static final Pattern VALID_REPLY; + public static final int VALID_REPLY_GROUP_USERNAME = 1; + + public static final Pattern INVALID_MENTION_MATCH_END; + + /** + * Regex to extract URL (it also includes the text preceding the url). + * + * This regex does not reflect its name and {@link Regex#VALID_URL_GROUP_URL} match + * should be checked in order to match a valid url. This is not ideal, but the behavior is + * being kept to ensure backwards compatibility. Ideally this regex should be + * implemented with a negative lookbehind as opposed to a negated character class + * but lack of JS support increases maint overhead if the logic is different by + * platform. + */ + + public static final Pattern VALID_URL; + public static final int VALID_URL_GROUP_ALL = 1; + public static final int VALID_URL_GROUP_BEFORE = 2; + public static final int VALID_URL_GROUP_URL = 3; + public static final int VALID_URL_GROUP_PROTOCOL = 4; + public static final int VALID_URL_GROUP_DOMAIN = 5; + public static final int VALID_URL_GROUP_PORT = 6; + public static final int VALID_URL_GROUP_PATH = 7; + public static final int VALID_URL_GROUP_QUERY_STRING = 8; + + public static final Pattern VALID_TCO_URL; + public static final Pattern INVALID_URL_WITHOUT_PROTOCOL_MATCH_BEGIN; + + public static final Pattern VALID_CASHTAG; + public static final int VALID_CASHTAG_GROUP_BEFORE = 1; + public static final int VALID_CASHTAG_GROUP_DOLLAR = 2; + public static final int VALID_CASHTAG_GROUP_CASHTAG = 3; + + public static final Pattern VALID_DOMAIN; + + // initializing in a static synchronized block, + // there appears to be thread safety issues with Pattern.compile in android + static { + synchronized (Regex.class) { + INVALID_CHARACTERS_PATTERN = Pattern.compile(".*[" + INVALID_CHARACTERS + "].*"); + VALID_HASHTAG = Pattern.compile("(^|\\uFE0E|\\uFE0F|[^&" + HASHTAG_LETTERS_NUMERALS + + "])([#\uFF03])(?![\uFE0F\u20E3])(" + HASHTAG_LETTERS_NUMERALS_SET + "*" + + HASHTAG_LETTERS_SET + HASHTAG_LETTERS_NUMERALS_SET + "*)", Pattern.CASE_INSENSITIVE); + INVALID_HASHTAG_MATCH_END = Pattern.compile("^(?:[##]|://)"); + RTL_CHARACTERS = Pattern.compile("[\u0600-\u06FF\u0750-\u077F\u0590-\u05FF\uFE70-\uFEFF]"); + AT_SIGNS = Pattern.compile("[" + AT_SIGNS_CHARS + "]"); + VALID_MENTION_OR_LIST = Pattern.compile("([^a-z0-9_!#$%&*" + AT_SIGNS_CHARS + + "]|^|(?:^|[^a-z0-9_+~.-])RT:?)(" + AT_SIGNS + + "+)([a-z0-9_]{1,20})(/[a-z][a-z0-9_\\-]{0,24})?", Pattern.CASE_INSENSITIVE); + VALID_REPLY = Pattern.compile("^(?:" + UNICODE_SPACES + "|" + DIRECTIONAL_CHARACTERS + ")*" + + AT_SIGNS + "([a-z0-9_]{1,20})", Pattern.CASE_INSENSITIVE); + INVALID_MENTION_MATCH_END = + Pattern.compile("^(?:[" + AT_SIGNS_CHARS + LATIN_ACCENTS_CHARS + "]|://)"); + INVALID_URL_WITHOUT_PROTOCOL_MATCH_BEGIN = Pattern.compile("[-_./]$"); + + VALID_URL = Pattern.compile(VALID_URL_PATTERN_STRING, Pattern.CASE_INSENSITIVE); + VALID_TCO_URL = Pattern.compile("^https?://t\\.co/([a-z0-9]+)(?:\\?" + + URL_VALID_URL_QUERY_CHARS + "*" + URL_VALID_URL_QUERY_ENDING_CHARS + ")?", + Pattern.CASE_INSENSITIVE); + VALID_CASHTAG = Pattern.compile("(^|" + UNICODE_SPACES + "|" + DIRECTIONAL_CHARACTERS + ")(" + + DOLLAR_SIGN_CHAR + ")(" + CASHTAG + ")" + "(?=$|\\s|\\p{Punct})", + Pattern.CASE_INSENSITIVE); + VALID_DOMAIN = Pattern.compile(URL_VALID_DOMAIN, Pattern.CASE_INSENSITIVE); + } + } + + private static String join(@Nonnull Collection col) { + final StringBuilder sb = new StringBuilder(); + final Iterator iter = col.iterator(); + if (iter.hasNext()) { + sb.append(iter.next().toString()); + } + while (iter.hasNext()) { + sb.append("|"); + sb.append(iter.next().toString()); + } + return sb.toString(); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/twittertext/TldLists.java b/app/src/main/java/com/keylesspalace/tusky/util/twittertext/TldLists.java new file mode 100644 index 000000000..220bdcf30 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/twittertext/TldLists.java @@ -0,0 +1,1593 @@ +// Copyright 2018 Twitter, Inc. +// Licensed under the Apache License, Version 2.0 +// http://www.apache.org/licenses/LICENSE-2.0 + +package com.keylesspalace.tusky.util.twittertext; + +import java.util.Arrays; +import java.util.List; + +public final class TldLists { + private TldLists() { + } + + public static final List GTLDS = Arrays.asList( + "삼성", + "닷컴", + "닷넷", + "香格里拉", + "餐厅", + "食品", + "飞利浦", + "電訊盈科", + "集团", + "通販", + "购物", + "谷歌", + "诺基亚", + "联通", + "网络", + "网站", + "网店", + "网址", + "组织机构", + "移动", + "珠宝", + "点看", + "游戏", + "淡马锡", + "机构", + "書籍", + "时尚", + "新闻", + "政府", + "政务", + "招聘", + "手表", + "手机", + "我爱你", + "慈善", + "微博", + "广东", + "工行", + "家電", + "娱乐", + "天主教", + "大拿", + "大众汽车", + "在线", + "嘉里大酒店", + "嘉里", + "商标", + "商店", + "商城", + "公益", + "公司", + "八卦", + "健康", + "信息", + "佛山", + "企业", + "中文网", + "中信", + "世界", + "ポイント", + "ファッション", + "セール", + "ストア", + "コム", + "グーグル", + "クラウド", + "みんな", + "คอม", + "संगठन", + "नेट", + "कॉम", + "همراه", + "موقع", + "موبايلي", + "كوم", + "كاثوليك", + "عرب", + "شبكة", + "بيتك", + "بازار", + "العليان", + "ارامكو", + "اتصالات", + "ابوظبي", + "קום", + "сайт", + "рус", + "орг", + "онлайн", + "москва", + "ком", + "католик", + "дети", + "zuerich", + "zone", + "zippo", + "zip", + "zero", + "zara", + "zappos", + "yun", + "youtube", + "you", + "yokohama", + "yoga", + "yodobashi", + "yandex", + "yamaxun", + "yahoo", + "yachts", + "xyz", + "xxx", + "xperia", + "xin", + "xihuan", + "xfinity", + "xerox", + "xbox", + "wtf", + "wtc", + "wow", + "world", + "works", + "work", + "woodside", + "wolterskluwer", + "wme", + "winners", + "wine", + "windows", + "win", + "williamhill", + "wiki", + "wien", + "whoswho", + "weir", + "weibo", + "wedding", + "wed", + "website", + "weber", + "webcam", + "weatherchannel", + "weather", + "watches", + "watch", + "warman", + "wanggou", + "wang", + "walter", + "walmart", + "wales", + "vuelos", + "voyage", + "voto", + "voting", + "vote", + "volvo", + "volkswagen", + "vodka", + "vlaanderen", + "vivo", + "viva", + "vistaprint", + "vista", + "vision", + "visa", + "virgin", + "vip", + "vin", + "villas", + "viking", + "vig", + "video", + "viajes", + "vet", + "versicherung", + "vermögensberatung", + "vermögensberater", + "verisign", + "ventures", + "vegas", + "vanguard", + "vana", + "vacations", + "ups", + "uol", + "uno", + "university", + "unicom", + "uconnect", + "ubs", + "ubank", + "tvs", + "tushu", + "tunes", + "tui", + "tube", + "trv", + "trust", + "travelersinsurance", + "travelers", + "travelchannel", + "travel", + "training", + "trading", + "trade", + "toys", + "toyota", + "town", + "tours", + "total", + "toshiba", + "toray", + "top", + "tools", + "tokyo", + "today", + "tmall", + "tkmaxx", + "tjx", + "tjmaxx", + "tirol", + "tires", + "tips", + "tiffany", + "tienda", + "tickets", + "tiaa", + "theatre", + "theater", + "thd", + "teva", + "tennis", + "temasek", + "telefonica", + "telecity", + "tel", + "technology", + "tech", + "team", + "tdk", + "tci", + "taxi", + "tax", + "tattoo", + "tatar", + "tatamotors", + "target", + "taobao", + "talk", + "taipei", + "tab", + "systems", + "symantec", + "sydney", + "swiss", + "swiftcover", + "swatch", + "suzuki", + "surgery", + "surf", + "support", + "supply", + "supplies", + "sucks", + "style", + "study", + "studio", + "stream", + "store", + "storage", + "stockholm", + "stcgroup", + "stc", + "statoil", + "statefarm", + "statebank", + "starhub", + "star", + "staples", + "stada", + "srt", + "srl", + "spreadbetting", + "spot", + "sport", + "spiegel", + "space", + "soy", + "sony", + "song", + "solutions", + "solar", + "sohu", + "software", + "softbank", + "social", + "soccer", + "sncf", + "smile", + "smart", + "sling", + "skype", + "sky", + "skin", + "ski", + "site", + "singles", + "sina", + "silk", + "shriram", + "showtime", + "show", + "shouji", + "shopping", + "shop", + "shoes", + "shiksha", + "shia", + "shell", + "shaw", + "sharp", + "shangrila", + "sfr", + "sexy", + "sex", + "sew", + "seven", + "ses", + "services", + "sener", + "select", + "seek", + "security", + "secure", + "seat", + "search", + "scot", + "scor", + "scjohnson", + "science", + "schwarz", + "schule", + "school", + "scholarships", + "schmidt", + "schaeffler", + "scb", + "sca", + "sbs", + "sbi", + "saxo", + "save", + "sas", + "sarl", + "sapo", + "sap", + "sanofi", + "sandvikcoromant", + "sandvik", + "samsung", + "samsclub", + "salon", + "sale", + "sakura", + "safety", + "safe", + "saarland", + "ryukyu", + "rwe", + "run", + "ruhr", + "rugby", + "rsvp", + "room", + "rogers", + "rodeo", + "rocks", + "rocher", + "rmit", + "rip", + "rio", + "ril", + "rightathome", + "ricoh", + "richardli", + "rich", + "rexroth", + "reviews", + "review", + "restaurant", + "rest", + "republican", + "report", + "repair", + "rentals", + "rent", + "ren", + "reliance", + "reit", + "reisen", + "reise", + "rehab", + "redumbrella", + "redstone", + "red", + "recipes", + "realty", + "realtor", + "realestate", + "read", + "raid", + "radio", + "racing", + "qvc", + "quest", + "quebec", + "qpon", + "pwc", + "pub", + "prudential", + "pru", + "protection", + "property", + "properties", + "promo", + "progressive", + "prof", + "productions", + "prod", + "pro", + "prime", + "press", + "praxi", + "pramerica", + "post", + "porn", + "politie", + "poker", + "pohl", + "pnc", + "plus", + "plumbing", + "playstation", + "play", + "place", + "pizza", + "pioneer", + "pink", + "ping", + "pin", + "pid", + "pictures", + "pictet", + "pics", + "piaget", + "physio", + "photos", + "photography", + "photo", + "phone", + "philips", + "phd", + "pharmacy", + "pfizer", + "pet", + "pccw", + "pay", + "passagens", + "party", + "parts", + "partners", + "pars", + "paris", + "panerai", + "panasonic", + "pamperedchef", + "page", + "ovh", + "ott", + "otsuka", + "osaka", + "origins", + "orientexpress", + "organic", + "org", + "orange", + "oracle", + "open", + "ooo", + "onyourside", + "online", + "onl", + "ong", + "one", + "omega", + "ollo", + "oldnavy", + "olayangroup", + "olayan", + "okinawa", + "office", + "off", + "observer", + "obi", + "nyc", + "ntt", + "nrw", + "nra", + "nowtv", + "nowruz", + "now", + "norton", + "northwesternmutual", + "nokia", + "nissay", + "nissan", + "ninja", + "nikon", + "nike", + "nico", + "nhk", + "ngo", + "nfl", + "nexus", + "nextdirect", + "next", + "news", + "newholland", + "new", + "neustar", + "network", + "netflix", + "netbank", + "net", + "nec", + "nba", + "navy", + "natura", + "nationwide", + "name", + "nagoya", + "nadex", + "nab", + "mutuelle", + "mutual", + "museum", + "mtr", + "mtpc", + "mtn", + "msd", + "movistar", + "movie", + "mov", + "motorcycles", + "moto", + "moscow", + "mortgage", + "mormon", + "mopar", + "montblanc", + "monster", + "money", + "monash", + "mom", + "moi", + "moe", + "moda", + "mobily", + "mobile", + "mobi", + "mma", + "mls", + "mlb", + "mitsubishi", + "mit", + "mint", + "mini", + "mil", + "microsoft", + "miami", + "metlife", + "merckmsd", + "meo", + "menu", + "men", + "memorial", + "meme", + "melbourne", + "meet", + "media", + "med", + "mckinsey", + "mcdonalds", + "mcd", + "mba", + "mattel", + "maserati", + "marshalls", + "marriott", + "markets", + "marketing", + "market", + "map", + "mango", + "management", + "man", + "makeup", + "maison", + "maif", + "madrid", + "macys", + "luxury", + "luxe", + "lupin", + "lundbeck", + "ltda", + "ltd", + "lplfinancial", + "lpl", + "love", + "lotto", + "lotte", + "london", + "lol", + "loft", + "locus", + "locker", + "loans", + "loan", + "llp", + "llc", + "lixil", + "living", + "live", + "lipsy", + "link", + "linde", + "lincoln", + "limo", + "limited", + "lilly", + "like", + "lighting", + "lifestyle", + "lifeinsurance", + "life", + "lidl", + "liaison", + "lgbt", + "lexus", + "lego", + "legal", + "lefrak", + "leclerc", + "lease", + "lds", + "lawyer", + "law", + "latrobe", + "latino", + "lat", + "lasalle", + "lanxess", + "landrover", + "land", + "lancome", + "lancia", + "lancaster", + "lamer", + "lamborghini", + "ladbrokes", + "lacaixa", + "kyoto", + "kuokgroup", + "kred", + "krd", + "kpn", + "kpmg", + "kosher", + "komatsu", + "koeln", + "kiwi", + "kitchen", + "kindle", + "kinder", + "kim", + "kia", + "kfh", + "kerryproperties", + "kerrylogistics", + "kerryhotels", + "kddi", + "kaufen", + "juniper", + "juegos", + "jprs", + "jpmorgan", + "joy", + "jot", + "joburg", + "jobs", + "jnj", + "jmp", + "jll", + "jlc", + "jio", + "jewelry", + "jetzt", + "jeep", + "jcp", + "jcb", + "java", + "jaguar", + "iwc", + "iveco", + "itv", + "itau", + "istanbul", + "ist", + "ismaili", + "iselect", + "irish", + "ipiranga", + "investments", + "intuit", + "international", + "intel", + "int", + "insure", + "insurance", + "institute", + "ink", + "ing", + "info", + "infiniti", + "industries", + "inc", + "immobilien", + "immo", + "imdb", + "imamat", + "ikano", + "iinet", + "ifm", + "ieee", + "icu", + "ice", + "icbc", + "ibm", + "hyundai", + "hyatt", + "hughes", + "htc", + "hsbc", + "how", + "house", + "hotmail", + "hotels", + "hoteles", + "hot", + "hosting", + "host", + "hospital", + "horse", + "honeywell", + "honda", + "homesense", + "homes", + "homegoods", + "homedepot", + "holiday", + "holdings", + "hockey", + "hkt", + "hiv", + "hitachi", + "hisamitsu", + "hiphop", + "hgtv", + "hermes", + "here", + "helsinki", + "help", + "healthcare", + "health", + "hdfcbank", + "hdfc", + "hbo", + "haus", + "hangout", + "hamburg", + "hair", + "guru", + "guitars", + "guide", + "guge", + "gucci", + "guardian", + "group", + "grocery", + "gripe", + "green", + "gratis", + "graphics", + "grainger", + "gov", + "got", + "gop", + "google", + "goog", + "goodyear", + "goodhands", + "goo", + "golf", + "goldpoint", + "gold", + "godaddy", + "gmx", + "gmo", + "gmbh", + "gmail", + "globo", + "global", + "gle", + "glass", + "glade", + "giving", + "gives", + "gifts", + "gift", + "ggee", + "george", + "genting", + "gent", + "gea", + "gdn", + "gbiz", + "gay", + "garden", + "gap", + "games", + "game", + "gallup", + "gallo", + "gallery", + "gal", + "fyi", + "futbol", + "furniture", + "fund", + "fun", + "fujixerox", + "fujitsu", + "ftr", + "frontier", + "frontdoor", + "frogans", + "frl", + "fresenius", + "free", + "fox", + "foundation", + "forum", + "forsale", + "forex", + "ford", + "football", + "foodnetwork", + "food", + "foo", + "fly", + "flsmidth", + "flowers", + "florist", + "flir", + "flights", + "flickr", + "fitness", + "fit", + "fishing", + "fish", + "firmdale", + "firestone", + "fire", + "financial", + "finance", + "final", + "film", + "fido", + "fidelity", + "fiat", + "ferrero", + "ferrari", + "feedback", + "fedex", + "fast", + "fashion", + "farmers", + "farm", + "fans", + "fan", + "family", + "faith", + "fairwinds", + "fail", + "fage", + "extraspace", + "express", + "exposed", + "expert", + "exchange", + "everbank", + "events", + "eus", + "eurovision", + "etisalat", + "esurance", + "estate", + "esq", + "erni", + "ericsson", + "equipment", + "epson", + "epost", + "enterprises", + "engineering", + "engineer", + "energy", + "emerck", + "email", + "education", + "edu", + "edeka", + "eco", + "eat", + "earth", + "dvr", + "dvag", + "durban", + "dupont", + "duns", + "dunlop", + "duck", + "dubai", + "dtv", + "drive", + "download", + "dot", + "doosan", + "domains", + "doha", + "dog", + "dodge", + "doctor", + "docs", + "dnp", + "diy", + "dish", + "discover", + "discount", + "directory", + "direct", + "digital", + "diet", + "diamonds", + "dhl", + "dev", + "design", + "desi", + "dentist", + "dental", + "democrat", + "delta", + "deloitte", + "dell", + "delivery", + "degree", + "deals", + "dealer", + "deal", + "dds", + "dclk", + "day", + "datsun", + "dating", + "date", + "data", + "dance", + "dad", + "dabur", + "cyou", + "cymru", + "cuisinella", + "csc", + "cruises", + "cruise", + "crs", + "crown", + "cricket", + "creditunion", + "creditcard", + "credit", + "cpa", + "courses", + "coupons", + "coupon", + "country", + "corsica", + "coop", + "cool", + "cookingchannel", + "cooking", + "contractors", + "contact", + "consulting", + "construction", + "condos", + "comsec", + "computer", + "compare", + "company", + "community", + "commbank", + "comcast", + "com", + "cologne", + "college", + "coffee", + "codes", + "coach", + "clubmed", + "club", + "cloud", + "clothing", + "clinique", + "clinic", + "click", + "cleaning", + "claims", + "cityeats", + "city", + "citic", + "citi", + "citadel", + "cisco", + "circle", + "cipriani", + "church", + "chrysler", + "chrome", + "christmas", + "chloe", + "chintai", + "cheap", + "chat", + "chase", + "charity", + "channel", + "chanel", + "cfd", + "cfa", + "cern", + "ceo", + "center", + "ceb", + "cbs", + "cbre", + "cbn", + "cba", + "catholic", + "catering", + "cat", + "casino", + "cash", + "caseih", + "case", + "casa", + "cartier", + "cars", + "careers", + "career", + "care", + "cards", + "caravan", + "car", + "capitalone", + "capital", + "capetown", + "canon", + "cancerresearch", + "camp", + "camera", + "cam", + "calvinklein", + "call", + "cal", + "cafe", + "cab", + "bzh", + "buzz", + "buy", + "business", + "builders", + "build", + "bugatti", + "budapest", + "brussels", + "brother", + "broker", + "broadway", + "bridgestone", + "bradesco", + "box", + "boutique", + "bot", + "boston", + "bostik", + "bosch", + "boots", + "booking", + "book", + "boo", + "bond", + "bom", + "bofa", + "boehringer", + "boats", + "bnpparibas", + "bnl", + "bmw", + "bms", + "blue", + "bloomberg", + "blog", + "blockbuster", + "blanco", + "blackfriday", + "black", + "biz", + "bio", + "bingo", + "bing", + "bike", + "bid", + "bible", + "bharti", + "bet", + "bestbuy", + "best", + "berlin", + "bentley", + "beer", + "beauty", + "beats", + "bcn", + "bcg", + "bbva", + "bbt", + "bbc", + "bayern", + "bauhaus", + "basketball", + "baseball", + "bargains", + "barefoot", + "barclays", + "barclaycard", + "barcelona", + "bar", + "bank", + "band", + "bananarepublic", + "banamex", + "baidu", + "baby", + "azure", + "axa", + "aws", + "avianca", + "autos", + "auto", + "author", + "auspost", + "audio", + "audible", + "audi", + "auction", + "attorney", + "athleta", + "associates", + "asia", + "asda", + "arte", + "art", + "arpa", + "army", + "archi", + "aramco", + "arab", + "aquarelle", + "apple", + "app", + "apartments", + "aol", + "anz", + "anquan", + "android", + "analytics", + "amsterdam", + "amica", + "amfam", + "amex", + "americanfamily", + "americanexpress", + "alstom", + "alsace", + "ally", + "allstate", + "allfinanz", + "alipay", + "alibaba", + "alfaromeo", + "akdn", + "airtel", + "airforce", + "airbus", + "aigo", + "aig", + "agency", + "agakhan", + "africa", + "afl", + "afamilycompany", + "aetna", + "aero", + "aeg", + "adult", + "ads", + "adac", + "actor", + "active", + "aco", + "accountants", + "accountant", + "accenture", + "academy", + "abudhabi", + "abogado", + "able", + "abc", + "abbvie", + "abbott", + "abb", + "abarth", + "aarp", + "aaa", + "onion" + ); + + public static final List CTLDS = Arrays.asList( + "한국", + "香港", + "澳門", + "新加坡", + "台灣", + "台湾", + "中國", + "中国", + "გე", + "ລາວ", + "ไทย", + "ලංකා", + "ഭാരതം", + "ಭಾರತ", + "భారత్", + "சிங்கப்பூர்", + "இலங்கை", + "இந்தியா", + "ଭାରତ", + "ભારત", + "ਭਾਰਤ", + "ভাৰত", + "ভারত", + "বাংলা", + "भारोत", + "भारतम्", + "भारत", + "ڀارت", + "پاکستان", + "موريتانيا", + "مليسيا", + "مصر", + "قطر", + "فلسطين", + "عمان", + "عراق", + "سورية", + "سودان", + "تونس", + "بھارت", + "بارت", + "ایران", + "امارات", + "المغرب", + "السعودية", + "الجزائر", + "البحرين", + "الاردن", + "հայ", + "қаз", + "укр", + "срб", + "рф", + "мон", + "мкд", + "ею", + "бел", + "бг", + "ευ", + "ελ", + "zw", + "zm", + "za", + "yt", + "ye", + "ws", + "wf", + "vu", + "vn", + "vi", + "vg", + "ve", + "vc", + "va", + "uz", + "uy", + "us", + "um", + "uk", + "ug", + "ua", + "tz", + "tw", + "tv", + "tt", + "tr", + "tp", + "to", + "tn", + "tm", + "tl", + "tk", + "tj", + "th", + "tg", + "tf", + "td", + "tc", + "sz", + "sy", + "sx", + "sv", + "su", + "st", + "ss", + "sr", + "so", + "sn", + "sm", + "sl", + "sk", + "sj", + "si", + "sh", + "sg", + "se", + "sd", + "sc", + "sb", + "sa", + "rw", + "ru", + "rs", + "ro", + "re", + "qa", + "py", + "pw", + "pt", + "ps", + "pr", + "pn", + "pm", + "pl", + "pk", + "ph", + "pg", + "pf", + "pe", + "pa", + "om", + "nz", + "nu", + "nr", + "np", + "no", + "nl", + "ni", + "ng", + "nf", + "ne", + "nc", + "na", + "mz", + "my", + "mx", + "mw", + "mv", + "mu", + "mt", + "ms", + "mr", + "mq", + "mp", + "mo", + "mn", + "mm", + "ml", + "mk", + "mh", + "mg", + "mf", + "me", + "md", + "mc", + "ma", + "ly", + "lv", + "lu", + "lt", + "ls", + "lr", + "lk", + "li", + "lc", + "lb", + "la", + "kz", + "ky", + "kw", + "kr", + "kp", + "kn", + "km", + "ki", + "kh", + "kg", + "ke", + "jp", + "jo", + "jm", + "je", + "it", + "is", + "ir", + "iq", + "io", + "in", + "im", + "il", + "ie", + "id", + "hu", + "ht", + "hr", + "hn", + "hm", + "hk", + "gy", + "gw", + "gu", + "gt", + "gs", + "gr", + "gq", + "gp", + "gn", + "gm", + "gl", + "gi", + "gh", + "gg", + "gf", + "ge", + "gd", + "gb", + "ga", + "fr", + "fo", + "fm", + "fk", + "fj", + "fi", + "eu", + "et", + "es", + "er", + "eh", + "eg", + "ee", + "ec", + "dz", + "do", + "dm", + "dk", + "dj", + "de", + "cz", + "cy", + "cx", + "cw", + "cv", + "cu", + "cr", + "co", + "cn", + "cm", + "cl", + "ck", + "ci", + "ch", + "cg", + "cf", + "cd", + "cc", + "ca", + "bz", + "by", + "bw", + "bv", + "bt", + "bs", + "br", + "bq", + "bo", + "bn", + "bm", + "bl", + "bj", + "bi", + "bh", + "bg", + "bf", + "be", + "bd", + "bb", + "ba", + "az", + "ax", + "aw", + "au", + "at", + "as", + "ar", + "aq", + "ao", + "an", + "am", + "al", + "ai", + "ag", + "af", + "ae", + "ad", + "ac" + ); +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt b/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt index c33533628..56492853e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt @@ -15,6 +15,8 @@ import com.keylesspalace.tusky.databinding.ViewBackgroundMessageBinding import com.keylesspalace.tusky.util.addDrawables import com.keylesspalace.tusky.util.getDrawableRes import com.keylesspalace.tusky.util.getErrorString +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.visible /** @@ -61,7 +63,7 @@ class BackgroundMessageView @JvmOverloads constructor( binding.imageView.setImageResource(imageRes) binding.button.setOnClickListener(clickListener) binding.button.visible(clickListener != null) - binding.helpText.visible(false) + binding.helpTextCard.hide() } fun showHelp(@StringRes helpRes: Int) { @@ -72,6 +74,6 @@ class BackgroundMessageView @JvmOverloads constructor( binding.helpText.setText(textWithDrawables, TextView.BufferType.SPANNABLE) - binding.helpText.visible(true) + binding.helpTextCard.show() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt b/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt index 6915bde21..dab8716fb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt @@ -21,12 +21,16 @@ import android.graphics.Paint import android.graphics.Path import android.graphics.PathMeasure import android.graphics.Rect +import android.text.TextUtils import android.util.AttributeSet import android.view.View import androidx.annotation.ColorInt import androidx.annotation.Dimension import androidx.core.content.res.use +import com.google.android.material.R as materialR +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R +import java.util.Locale import kotlin.math.max class GraphView @JvmOverloads constructor( @@ -66,98 +70,67 @@ class GraphView @JvmOverloads constructor( private var primaryLinePath: Path = Path() private var secondaryLinePath: Path = Path() + private var isRtlLayout: Boolean = false + var maxTrendingValue: Long = 300 var primaryLineData: List = if (isInEditMode) { - listOf( - 30, - 60, - 70, - 80, - 130, - 190, - 80 - ) + listOf(30, 60, 70, 80, 130, 190, 80) } else { - listOf( - 1, - 1, - 1, - 1, - 1, - 1, - 1 - ) + listOf(1, 1, 1, 1, 1, 1, 1) } set(value) { field = value.map { max(1, it) } + if (isRtlLayout) { + field = field.reversed() + } primaryLinePath.reset() invalidate() } var secondaryLineData: List = if (isInEditMode) { - listOf( - 10, - 20, - 40, - 60, - 100, - 132, - 20 - ) + listOf(10, 20, 40, 60, 100, 132, 20) } else { - listOf( - 1, - 1, - 1, - 1, - 1, - 1, - 1 - ) + listOf(1, 1, 1, 1, 1, 1, 1) } set(value) { field = value.map { max(1, it) } + if (isRtlLayout) { + field = field.reversed() + } secondaryLinePath.reset() invalidate() } init { initFromXML(attrs) + isRtlLayout = TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()) == LAYOUT_DIRECTION_RTL } private fun initFromXML(attr: AttributeSet?) { context.obtainStyledAttributes(attr, R.styleable.GraphView).use { a -> - primaryLineColor = context.getColor( - a.getResourceId( - R.styleable.GraphView_primaryLineColor, - R.color.chinwag_green - ) + primaryLineColor = a.getColor( + R.styleable.GraphView_primaryLineColor, + MaterialColors.getColor(this, materialR.attr.colorPrimary) ) - secondaryLineColor = context.getColor( - a.getResourceId( - R.styleable.GraphView_secondaryLineColor, - R.color.tusky_red - ) + secondaryLineColor = a.getColor( + R.styleable.GraphView_secondaryLineColor, + context.getColor(R.color.warning_color) ) - lineWidth = a.getDimensionPixelSize( + lineWidth = a.getDimension( R.styleable.GraphView_lineWidth, - R.dimen.graph_line_thickness - ).toFloat() - - graphColor = context.getColor( - a.getResourceId( - R.styleable.GraphView_graphColor, - R.color.colorBackground - ) + context.resources.getDimension(R.dimen.graph_line_thickness) ) - metaColor = context.getColor( - a.getResourceId( - R.styleable.GraphView_metaColor, - R.color.dividerColor - ) + graphColor = a.getColor( + R.styleable.GraphView_graphColor, + context.getColor(R.color.colorBackground) + ) + + metaColor = a.getColor( + R.styleable.GraphView_metaColor, + context.getColor(R.color.dividerColor) ) proportionalTrending = a.getBoolean( @@ -297,7 +270,7 @@ class GraphView @JvmOverloads constructor( linePath = secondaryLinePath, linePaint = secondaryLinePaint, circlePaint = secondaryCirclePaint, - lineThickness = lineWidth + lineThickness = lineWidth, ) drawLine( canvas = canvas, @@ -323,8 +296,11 @@ class GraphView @JvmOverloads constructor( ) val pm = PathMeasure(linePath, false) + + val dotPosition = if (isRtlLayout) 0f else pm.length + val coord = floatArrayOf(0f, 0f) - pm.getPosTan(pm.length * 1f, coord, null) + pm.getPosTan(dotPosition, coord, null) drawCircle(coord[0], coord[1], lineThickness * 2f, circlePaint) } diff --git a/app/src/main/java/com/keylesspalace/tusky/view/HashtagPickerDialog.kt b/app/src/main/java/com/keylesspalace/tusky/view/HashtagPickerDialog.kt new file mode 100644 index 000000000..2e3ccf918 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/HashtagPickerDialog.kt @@ -0,0 +1,118 @@ +/* Copyright 2025 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 . */ + +@file:JvmName("HashTagPickerDialog") + +package com.keylesspalace.tusky.view + +import android.content.Context +import android.util.Log +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.WindowManager +import android.view.inputmethod.EditorInfo +import android.widget.TextView +import android.widget.TextView.OnEditorActionListener +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.core.widget.doOnTextChanged +import at.connyduck.calladapter.networkresult.fold +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter +import com.keylesspalace.tusky.components.search.SearchType +import com.keylesspalace.tusky.databinding.DialogPickHashtagBinding +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.hashtagPattern +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking + +fun Context.showHashtagPickerDialog( + api: MastodonApi, + @StringRes title: Int, + onHashtagSelected: (String) -> Unit +) { + val dialogScope = CoroutineScope(Dispatchers.Main) + val dialogBinding = DialogPickHashtagBinding.inflate(LayoutInflater.from(this)) + val autocompleteTextView = dialogBinding.pickHashtagEditText + + val autoCompleteProvider = object : ComposeAutoCompleteAdapter.AutocompletionProvider { + override fun search(token: String): List { + return runBlocking { + api.search(query = token, type = SearchType.Hashtag.apiParameter, limit = 5) + .fold({ searchResult -> + searchResult.hashtags.map { + ComposeAutoCompleteAdapter.AutocompleteResult.HashtagResult( + it.name + ) + } + }, { e -> + Log.e("HashtagPickerDialog", "Autocomplete search for $token failed", e) + emptyList() + }) + } + } + } + + autocompleteTextView.setAdapter( + ComposeAutoCompleteAdapter( + autoCompleteProvider, + animateAvatar = false, + animateEmojis = false, + showBotBadge = false, + withDecoration = false + ) + ) + + autocompleteTextView.setSelection(autocompleteTextView.length()) + + val dialog = MaterialAlertDialogBuilder(this) + .setTitle(title) + .setView(dialogBinding.root) + .setPositiveButton(android.R.string.ok) { _, _ -> + onHashtagSelected(autocompleteTextView.text.toString()) + } + .setNegativeButton(android.R.string.cancel, null) + .setOnDismissListener { + dialogScope.cancel() + } + .create() + + autocompleteTextView.doOnTextChanged { s, _, _, _ -> + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(s) + } + + autocompleteTextView.setOnEditorActionListener(object : OnEditorActionListener { + override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean { + if (actionId == EditorInfo.IME_ACTION_DONE && validateHashtag(autocompleteTextView.text)) { + onHashtagSelected(autocompleteTextView.text.toString()) + dialog.dismiss() + return true + } + return false + } + }) + + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + dialog.show() + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(autocompleteTextView.text) + autocompleteTextView.requestFocus() +} + +private fun validateHashtag(input: CharSequence?): Boolean { + val trimmedInput = input?.trim() ?: "" + return trimmedInput.isNotEmpty() && hashtagPattern.matcher(trimmedInput).matches() +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt index 1b232eae1..8a3f6e4e9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt @@ -16,12 +16,11 @@ package com.keylesspalace.tusky.view import android.content.Context -import android.graphics.Color import android.util.AttributeSet import android.view.LayoutInflater import androidx.core.content.res.use +import com.google.android.material.R as materialR import com.google.android.material.card.MaterialCardView -import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.CardLicenseBinding import com.keylesspalace.tusky.util.hide @@ -31,20 +30,12 @@ class LicenseCard @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = 0 + defStyleAttr: Int = materialR.attr.materialCardViewFilledStyle ) : MaterialCardView(context, attrs, defStyleAttr) { init { val binding = CardLicenseBinding.inflate(LayoutInflater.from(context), this) - setCardBackgroundColor( - MaterialColors.getColor( - context, - com.google.android.material.R.attr.colorSurface, - Color.BLACK - ) - ) - val (name, license, link) = context.theme.obtainStyledAttributes( attrs, R.styleable.LicenseCard, diff --git a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewLayout.kt b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewLayout.kt index 854b72009..1d26d5b3b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewLayout.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewLayout.kt @@ -189,8 +189,8 @@ class MediaPreviewLayout(context: Context, attrs: AttributeSet? = null) : val wrapper = getChildAt(index) action( index, - wrapper.findViewById(R.id.preview_image_view) as MediaPreviewImageView, - wrapper.findViewById(R.id.preview_media_description_indicator) as TextView + wrapper.findViewById(R.id.preview_image_view), + wrapper.findViewById(R.id.preview_media_description_indicator) ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/view/MuteAccountDialog.kt b/app/src/main/java/com/keylesspalace/tusky/view/MuteAccountDialog.kt index 715fa6033..43b7ed5d3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/MuteAccountDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/MuteAccountDialog.kt @@ -3,7 +3,7 @@ package com.keylesspalace.tusky.view import android.app.Activity -import androidx.appcompat.app.AlertDialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.DialogMuteAccountBinding @@ -16,17 +16,26 @@ fun showMuteAccountDialog( binding.warning.text = activity.getString(R.string.dialog_mute_warning, accountUsername) binding.checkbox.isChecked = true - AlertDialog.Builder(activity) + val durationLabels = activity.resources.getStringArray(R.array.mute_duration_names) + binding.durationDropDown.setSimpleItems(durationLabels) + + var selectedDurationIndex = 0 + binding.durationDropDown.setOnItemClickListener { _, _, position, _ -> + selectedDurationIndex = position + } + binding.durationDropDown.setText(durationLabels[selectedDurationIndex], false) + + MaterialAlertDialogBuilder(activity) .setView(binding.root) .setPositiveButton(android.R.string.ok) { _, _ -> val durationValues = activity.resources.getIntArray(R.array.mute_duration_values) // workaround to make indefinite muting work with Mastodon 3.3.0 // https://github.com/tuskyapp/Tusky/issues/2107 - val duration = if (binding.duration.selectedItemPosition == 0) { + val duration = if (selectedDurationIndex == 0) { null } else { - durationValues[binding.duration.selectedItemPosition] + durationValues[selectedDurationIndex] } onOk(binding.checkbox.isChecked, duration) diff --git a/app/src/main/java/com/keylesspalace/tusky/view/TuskySwipeRefreshLayout.kt b/app/src/main/java/com/keylesspalace/tusky/view/TuskySwipeRefreshLayout.kt new file mode 100644 index 000000000..801f39c00 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/TuskySwipeRefreshLayout.kt @@ -0,0 +1,37 @@ +/* Copyright 2024 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.view + +import android.content.Context +import android.util.AttributeSet +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.google.android.material.R as materialR +import com.google.android.material.color.MaterialColors + +/** + * SwipeRefreshLayout does not allow theming of the color scheme, + * so we use this class to still have a single point to change its colors. + */ +class TuskySwipeRefreshLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : SwipeRefreshLayout(context, attrs) { + + init { + setColorSchemeColors( + MaterialColors.getColor(this, materialR.attr.colorPrimary) + ) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt index 2626cfeef..1881c52ea 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt @@ -17,7 +17,6 @@ package com.keylesspalace.tusky.viewdata import android.os.Parcelable import com.keylesspalace.tusky.entity.Attachment -import com.keylesspalace.tusky.entity.Status import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @@ -36,17 +35,16 @@ data class AttachmentViewData( companion object { @JvmStatic fun list( - status: Status, + status: StatusViewData.Concrete, alwaysShowSensitiveMedia: Boolean = false ): List { - val actionable = status.actionableStatus - return actionable.attachments.map { attachment -> + return status.attachments.map { attachment -> AttachmentViewData( attachment = attachment, - statusId = actionable.id, - statusUrl = actionable.url!!, - sensitive = actionable.sensitive, - isRevealed = alwaysShowSensitiveMedia || !actionable.sensitive + statusId = status.actionableId, + statusUrl = status.actionable.url!!, + sensitive = status.actionable.sensitive, + isRevealed = alwaysShowSensitiveMedia || !status.actionable.sensitive ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt index c70e2fc71..ed53bf603 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt @@ -1,4 +1,4 @@ -/* Copyright 2017 Andrew Dawson +/* Copyright 2023 Tusky Contributors * * This file is a part of Tusky. * @@ -12,127 +12,41 @@ * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ +package com.keylesspalace.tusky.viewdata -package com.keylesspalace.tusky.viewdata; +import com.keylesspalace.tusky.entity.AccountWarning +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.RelationshipSeveranceEvent +import com.keylesspalace.tusky.entity.Report +import com.keylesspalace.tusky.entity.TimelineAccount -import androidx.annotation.Nullable; +sealed class NotificationViewData { -import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Report; -import com.keylesspalace.tusky.entity.TimelineAccount; + abstract val id: String -import java.util.Objects; + abstract fun asStatusOrNull(): StatusViewData.Concrete? + abstract fun asPlaceholderOrNull(): Placeholder? -/** - * Created by charlag on 12/07/2017. - *

- * Class to represent data required to display either a notification or a placeholder. - * It is either a {@link Placeholder} or a {@link Concrete}. - * It is modelled this way because close relationship between placeholder and concrete notification - * is fine in this case. Placeholder case is not modelled as a type of notification because - * invariants would be violated and because it would model domain incorrectly. It is preferable to - * {@link com.keylesspalace.tusky.util.Either} because class hierarchy is cheaper, faster and - * more native. - */ -public abstract class NotificationViewData { - private NotificationViewData() { + data class Concrete( + override val id: String, + val type: Notification.Type, + val account: TimelineAccount, + val statusViewData: StatusViewData.Concrete?, + val report: Report?, + val event: RelationshipSeveranceEvent?, + val moderationWarning: AccountWarning? + ) : NotificationViewData() { + override fun asStatusOrNull() = statusViewData + + override fun asPlaceholderOrNull() = null } - public abstract long getViewDataId(); + data class Placeholder( + override val id: String, + val isLoading: Boolean + ) : NotificationViewData() { + override fun asStatusOrNull() = null - public abstract boolean deepEquals(NotificationViewData other); - - public static final class Concrete extends NotificationViewData { - private final Notification.Type type; - private final String id; - private final TimelineAccount account; - @Nullable - private final StatusViewData.Concrete statusViewData; - @Nullable - private final Report report; - - public Concrete(Notification.Type type, String id, TimelineAccount account, - @Nullable StatusViewData.Concrete statusViewData, @Nullable Report report) { - this.type = type; - this.id = id; - this.account = account; - this.statusViewData = statusViewData; - this.report = report; - } - - public Notification.Type getType() { - return type; - } - - public String getId() { - return id; - } - - public TimelineAccount getAccount() { - return account; - } - - @Nullable - public StatusViewData.Concrete getStatusViewData() { - return statusViewData; - } - - @Nullable - public Report getReport() { - return report; - } - - @Override - public long getViewDataId() { - return id.hashCode(); - } - - @Override - public boolean deepEquals(NotificationViewData o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Concrete concrete = (Concrete) o; - return type == concrete.type && - Objects.equals(id, concrete.id) && - account.getId().equals(concrete.account.getId()) && - (Objects.equals(statusViewData, concrete.statusViewData)) && - (Objects.equals(report, concrete.report)); - } - - @Override - public int hashCode() { - - return Objects.hash(type, id, account, statusViewData); - } - - public Concrete copyWithStatus(@Nullable StatusViewData.Concrete statusViewData) { - return new Concrete(type, id, account, statusViewData, report); - } - } - - public static final class Placeholder extends NotificationViewData { - private final long id; - private final boolean isLoading; - - public Placeholder(long id, boolean isLoading) { - this.id = id; - this.isLoading = isLoading; - } - - public boolean isLoading() { - return isLoading; - } - - @Override - public long getViewDataId() { - return id; - } - - @Override - public boolean deepEquals(NotificationViewData other) { - if (!(other instanceof Placeholder)) return false; - Placeholder that = (Placeholder) other; - return isLoading == that.isLoading && id == that.id; - } + override fun asPlaceholderOrNull() = this } } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index 6f8d5d698..597832003 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -18,6 +18,7 @@ import android.text.Spanned import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.entity.Translation import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.shouldTrimStatus @@ -55,6 +56,7 @@ sealed class StatusViewData { */ val isCollapsed: Boolean, val isDetailed: Boolean = false, + val repliedToAccount: TimelineAccount? = null, val translation: TranslationViewData? = null, ) : StatusViewData() { override val id: String @@ -82,10 +84,12 @@ sealed class StatusViewData { /** * Specifies whether the content of this post is long enough to be automatically * collapsed or if it should show all content regardless. + * Translated posts only show the button if the original post had it as well. * * @return Whether the post is collapsible or never collapsed. */ - val isCollapsible: Boolean = shouldTrimStatus(this.content) + val isCollapsible: Boolean = shouldTrimStatus(this.content) && + (translation == null || shouldTrimStatus(actionable.content.parseAsMastodonHtml())) val actionable: Status get() = status.actionableStatus @@ -103,20 +107,11 @@ sealed class StatusViewData { val rebloggingStatus: Status? get() = if (status.reblog != null) status else null - /** Helper for Java */ - fun copyWithStatus(status: Status): Concrete { - return copy(status = status) - } + val isReply: Boolean + get() = status.inReplyToAccountId != null - /** Helper for Java */ - fun copyWithExpanded(isExpanded: Boolean): Concrete { - return copy(isExpanded = isExpanded) - } - - /** Helper for Java */ - fun copyWithShowingContent(isShowingContent: Boolean): Concrete { - return copy(isShowingContent = isShowingContent) - } + val isSelfReply: Boolean + get() = status.inReplyToAccountId == status.account.id /** Helper for Java */ fun copyWithCollapsed(isCollapsed: Boolean): Concrete { diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt index 5cfb2a31e..4f000219e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt @@ -19,41 +19,42 @@ package com.keylesspalace.tusky.viewmodel import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.getOrDefault import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.Either -import com.keylesspalace.tusky.util.Either.Companion.map -import com.keylesspalace.tusky.util.Either.Left -import com.keylesspalace.tusky.util.Either.Right import com.keylesspalace.tusky.util.withoutFirstWhich +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch data class State( - val accounts: Either>, + val accounts: Result>, val searchResult: List? ) +@HiltViewModel class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() { val state: Flow get() = _state - private val _state = MutableStateFlow(State(Right(listOf()), null)) + private val _state = MutableStateFlow( + State( + accounts = Result.success(emptyList()), + searchResult = null + ) + ) fun load(listId: String) { val state = _state.value - if (state.accounts.isLeft() || state.accounts.asRight().isEmpty()) { + if (state.accounts.isFailure || state.accounts.getOrThrow().isEmpty()) { viewModelScope.launch { - api.getAccountsInList(listId, 0).fold( - { accounts -> - updateState { copy(accounts = Right(accounts)) } - }, - { e -> - updateState { copy(accounts = Left(e)) } - } - ) + val accounts = api.getAccountsInList(listId, 0) + _state.update { it.copy(accounts = accounts.toResult()) } } } } @@ -62,14 +63,14 @@ class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) viewModelScope.launch { api.addAccountToList(listId, listOf(account.id)) .fold( - { - updateState { - copy(accounts = accounts.map { it + account }) + onSuccess = { + _state.update { state -> + state.copy(accounts = state.accounts.map { it + account }) } }, - { + onFailure = { Log.i( - javaClass.simpleName, + AccountsInListViewModel::class.java.simpleName, "Failed to add account to list: ${account.username}" ) } @@ -81,18 +82,18 @@ class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) viewModelScope.launch { api.deleteAccountFromList(listId, listOf(accountId)) .fold( - { - updateState { - copy( - accounts = accounts.map { accounts -> + onSuccess = { + _state.update { state -> + state.copy( + accounts = state.accounts.map { accounts -> accounts.withoutFirstWhich { it.id == accountId } } ) } }, - { + onFailure = { Log.i( - javaClass.simpleName, + AccountsInListViewModel::class.java.simpleName, "Failed to remove account from list: $accountId" ) } @@ -100,25 +101,29 @@ class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) } } + private val currentQuery = MutableStateFlow("") + fun search(query: String) { - when { - query.isEmpty() -> updateState { copy(searchResult = null) } - query.isBlank() -> updateState { copy(searchResult = listOf()) } - else -> viewModelScope.launch { - api.searchAccounts(query, null, 10, true) - .fold( - { result -> - updateState { copy(searchResult = result) } - }, - { - updateState { copy(searchResult = listOf()) } - } - ) + currentQuery.value = query + } + + init { + viewModelScope.launch { + // Use collectLatest to automatically cancel the previous search + currentQuery.collectLatest { query -> + val searchResult = when { + query.isEmpty() -> null + query.isBlank() -> emptyList() + else -> api.searchAccounts(query, null, 10, true) + .getOrDefault(emptyList()) + } + _state.update { it.copy(searchResult = searchResult) } } } } - private inline fun updateState(fn: State.() -> State) { - _state.value = fn(_state.value) - } + private fun NetworkResult.toResult(): Result = fold( + onSuccess = { Result.success(it) }, + onFailure = { Result.failure(it) } + ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt index b04f5fdf6..4f1eadab0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt @@ -17,6 +17,7 @@ package com.keylesspalace.tusky.viewmodel import android.app.Application import android.net.Uri +import android.util.Log import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -25,6 +26,7 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository +import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.StringField import com.keylesspalace.tusky.network.MastodonApi @@ -34,6 +36,7 @@ import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.getServerErrorMessage import com.keylesspalace.tusky.util.randomAlphanumericString +import dagger.hilt.android.lifecycle.HiltViewModel import java.io.File import javax.inject.Inject import kotlinx.coroutines.flow.Flow @@ -59,13 +62,17 @@ internal data class ProfileDataInUi( val fields: List ) +@HiltViewModel class EditProfileViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub, private val application: Application, - private val instanceInfoRepo: InstanceInfoRepository + private val accountManager: AccountManager, + instanceInfoRepo: InstanceInfoRepository ) : ViewModel() { + private val activeAccount = accountManager.activeAccount!! + private val _profileData = MutableStateFlow(null as Resource?) val profileData: StateFlow?> = _profileData.asStateFlow() @@ -133,44 +140,46 @@ class EditProfileViewModel @Inject constructor( } viewModelScope.launch { - var avatarFileBody: MultipartBody.Part? = null - diff.avatarFile?.let { - avatarFileBody = MultipartBody.Part.createFormData( + val avatarFileBody: MultipartBody.Part? = diff.avatarFile?.let { + MultipartBody.Part.createFormData( "avatar", randomAlphanumericString(12), it.asRequestBody("image/png".toMediaTypeOrNull()) ) } - var headerFileBody: MultipartBody.Part? = null - diff.headerFile?.let { - headerFileBody = MultipartBody.Part.createFormData( + val headerFileBody: MultipartBody.Part? = diff.headerFile?.let { + MultipartBody.Part.createFormData( "header", randomAlphanumericString(12), it.asRequestBody("image/png".toMediaTypeOrNull()) ) } + val fieldsMap = diff.fields?.let { fields -> + buildMap { + fields.forEachIndexed { index, field -> + put("fields_attributes[$index][name]", field.name.toRequestBody(MultipartBody.FORM)) + put("fields_attributes[$index][value]", field.value.toRequestBody(MultipartBody.FORM)) + } + } + }.orEmpty() + mastodonApi.accountUpdateCredentials( - diff.displayName?.toRequestBody(MultipartBody.FORM), - diff.note?.toRequestBody(MultipartBody.FORM), - diff.locked?.toString()?.toRequestBody(MultipartBody.FORM), - avatarFileBody, - headerFileBody, - diff.field1?.first?.toRequestBody(MultipartBody.FORM), - diff.field1?.second?.toRequestBody(MultipartBody.FORM), - diff.field2?.first?.toRequestBody(MultipartBody.FORM), - diff.field2?.second?.toRequestBody(MultipartBody.FORM), - diff.field3?.first?.toRequestBody(MultipartBody.FORM), - diff.field3?.second?.toRequestBody(MultipartBody.FORM), - diff.field4?.first?.toRequestBody(MultipartBody.FORM), - diff.field4?.second?.toRequestBody(MultipartBody.FORM) + displayName = diff.displayName?.toRequestBody(MultipartBody.FORM), + note = diff.note?.toRequestBody(MultipartBody.FORM), + locked = diff.locked?.toString()?.toRequestBody(MultipartBody.FORM), + avatar = avatarFileBody, + header = headerFileBody, + fields = fieldsMap ).fold( { newAccountData -> - _saveData.value = Success() + accountManager.updateAccount(activeAccount, newAccountData) eventHub.dispatch(ProfileEditedEvent(newAccountData)) + _saveData.value = Success() }, { throwable -> + Log.d(TAG, "failed updating profile", throwable) _saveData.value = Error(errorMessage = throwable.getServerErrorMessage()) } ) @@ -229,28 +238,13 @@ class EditProfileViewModel @Inject constructor( } // when one field changed, all have to be sent or they unchanged ones would get overridden - val allFieldsUnchanged = oldProfileAccount?.source?.fields == newProfileData.fields - val field1 = calculateFieldToUpdate(newProfileData.fields.getOrNull(0), allFieldsUnchanged) - val field2 = calculateFieldToUpdate(newProfileData.fields.getOrNull(1), allFieldsUnchanged) - val field3 = calculateFieldToUpdate(newProfileData.fields.getOrNull(2), allFieldsUnchanged) - val field4 = calculateFieldToUpdate(newProfileData.fields.getOrNull(3), allFieldsUnchanged) - - return DiffProfileData( - displayName, note, locked, field1, field2, field3, field4, headerFile, avatarFile - ) - } - - private fun calculateFieldToUpdate( - newField: StringField?, - fieldsUnchanged: Boolean - ): Pair? { - if (fieldsUnchanged || newField == null) { - return null + val fields = if (oldProfileAccount?.source?.fields == newProfileData.fields) { + null + } else { + newProfileData.fields } - return Pair( - newField.name, - newField.value - ) + + return DiffProfileData(displayName, note, locked, fields, headerFile, avatarFile) } private fun getCacheFileForName(filename: String): File { @@ -261,15 +255,15 @@ class EditProfileViewModel @Inject constructor( val displayName: String?, val note: String?, val locked: Boolean?, - val field1: Pair?, - val field2: Pair?, - val field3: Pair?, - val field4: Pair?, + val fields: List?, val headerFile: File?, val avatarFile: File? ) { fun hasChanges() = displayName != null || note != null || locked != null || - avatarFile != null || headerFile != null || field1 != null || field2 != null || - field3 != null || field4 != null + avatarFile != null || headerFile != null || fields != null + } + + companion object { + private const val TAG = "EditProfileViewModel" } } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt index 4a9a6a484..4e9144d62 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt @@ -23,6 +23,7 @@ import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.replacedFirstWhich import com.keylesspalace.tusky.util.withoutFirstWhich +import dagger.hilt.android.lifecycle.HiltViewModel import java.io.IOException import java.net.ConnectException import javax.inject.Inject @@ -35,6 +36,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +@HiltViewModel internal class ListsViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() { enum class LoadingState { INITIAL, diff --git a/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt b/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt index a7362630c..7534b05b1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt +++ b/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt @@ -19,41 +19,39 @@ package com.keylesspalace.tusky.worker import android.app.Notification import android.content.Context +import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.notifications.NotificationFetcher -import com.keylesspalace.tusky.components.notifications.NotificationHelper -import com.keylesspalace.tusky.components.notifications.NotificationHelper.NOTIFICATION_ID_FETCH_NOTIFICATION -import javax.inject.Inject +import com.keylesspalace.tusky.components.systemnotifications.NotificationFetcher +import com.keylesspalace.tusky.components.systemnotifications.NotificationService +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject -/** Fetch and show new notifications. */ -class NotificationWorker( - appContext: Context, - params: WorkerParameters, - private val notificationsFetcher: NotificationFetcher +@HiltWorker +class NotificationWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val notificationsFetcher: NotificationFetcher, + notificationService: NotificationService, ) : CoroutineWorker(appContext, params) { - val notification: Notification = NotificationHelper.createWorkerNotification( - applicationContext, + val notification: Notification = notificationService.createWorkerNotification( R.string.notification_notification_worker ) override suspend fun doWork(): Result { - notificationsFetcher.fetchAndShow() + val accountId = inputData.getLong(KEY_ACCOUNT_ID, 0).takeIf { it != 0L } + notificationsFetcher.fetchAndShow(accountId) return Result.success() } override suspend fun getForegroundInfo() = ForegroundInfo( - NOTIFICATION_ID_FETCH_NOTIFICATION, + NotificationService.NOTIFICATION_ID_FETCH_NOTIFICATION, notification ) - class Factory @Inject constructor( - private val notificationsFetcher: NotificationFetcher - ) : ChildWorkerFactory { - override fun createWorker(appContext: Context, params: WorkerParameters): CoroutineWorker { - return NotificationWorker(appContext, params, notificationsFetcher) - } + companion object { + const val KEY_ACCOUNT_ID = "accountId" } } diff --git a/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt b/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt index c69a035a6..5c03bdb04 100644 --- a/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt +++ b/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt @@ -20,54 +20,51 @@ package com.keylesspalace.tusky.worker import android.app.Notification import android.content.Context import android.util.Log +import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.ForegroundInfo -import androidx.work.ListenableWorker import androidx.work.WorkerParameters import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.notifications.NotificationHelper -import com.keylesspalace.tusky.components.notifications.NotificationHelper.NOTIFICATION_ID_PRUNE_CACHE +import com.keylesspalace.tusky.components.systemnotifications.NotificationService import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.AppDatabase -import javax.inject.Inject +import com.keylesspalace.tusky.db.DatabaseCleaner +import com.keylesspalace.tusky.util.deleteStaleCachedMedia +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject /** Prune the database cache of old statuses. */ -class PruneCacheWorker( - appContext: Context, - workerParams: WorkerParameters, - private val appDatabase: AppDatabase, - private val accountManager: AccountManager +@HiltWorker +class PruneCacheWorker @AssistedInject constructor( + @Assisted private val appContext: Context, + @Assisted workerParams: WorkerParameters, + private val databaseCleaner: DatabaseCleaner, + private val accountManager: AccountManager, + val notificationService: NotificationService, ) : CoroutineWorker(appContext, workerParams) { - val notification: Notification = NotificationHelper.createWorkerNotification( - applicationContext, + val notification: Notification = notificationService.createWorkerNotification( R.string.notification_prune_cache ) override suspend fun doWork(): Result { for (account in accountManager.accounts) { Log.d(TAG, "Pruning database using account ID: ${account.id}") - appDatabase.timelineDao().cleanup(account.id, MAX_STATUSES_IN_CACHE) + databaseCleaner.cleanupOldData(account.id, MAX_HOMETIMELINE_ITEMS_IN_CACHE, MAX_NOTIFICATIONS_IN_CACHE) } + + deleteStaleCachedMedia(appContext.getExternalFilesDir("Tusky")) + return Result.success() } override suspend fun getForegroundInfo() = ForegroundInfo( - NOTIFICATION_ID_PRUNE_CACHE, + NotificationService.NOTIFICATION_ID_PRUNE_CACHE, notification ) companion object { private const val TAG = "PruneCacheWorker" - private const val MAX_STATUSES_IN_CACHE = 1000 + private const val MAX_HOMETIMELINE_ITEMS_IN_CACHE = 1000 + private const val MAX_NOTIFICATIONS_IN_CACHE = 1000 const val PERIODIC_WORK_TAG = "PruneCacheWorker_periodic" } - - class Factory @Inject constructor( - private val appDatabase: AppDatabase, - private val accountManager: AccountManager - ) : ChildWorkerFactory { - override fun createWorker(appContext: Context, params: WorkerParameters): ListenableWorker { - return PruneCacheWorker(appContext, params, appDatabase, accountManager) - } - } } diff --git a/app/src/main/java/com/keylesspalace/tusky/worker/WorkerFactory.kt b/app/src/main/java/com/keylesspalace/tusky/worker/WorkerFactory.kt deleted file mode 100644 index 1d44b2ea8..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/worker/WorkerFactory.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.worker - -import android.content.Context -import android.util.Log -import androidx.work.ListenableWorker -import androidx.work.WorkerFactory -import androidx.work.WorkerParameters -import javax.inject.Inject -import javax.inject.Provider -import javax.inject.Singleton - -/** - * Workers implement this and are added to the map in [com.keylesspalace.tusky.di.WorkerModule] - * so they can be created by [WorkerFactory.createWorker]. - */ -interface ChildWorkerFactory { - /** Create a new instance of the given worker. */ - fun createWorker(appContext: Context, params: WorkerParameters): ListenableWorker -} - -/** - * Creates workers, delegating to each worker's [ChildWorkerFactory.createWorker] to do the - * creation. - * - * @see [com.keylesspalace.tusky.worker.NotificationWorker] - */ -@Singleton -class WorkerFactory @Inject constructor( - private val workerFactories: Map, @JvmSuppressWildcards Provider> -) : WorkerFactory() { - override fun createWorker( - appContext: Context, - workerClassName: String, - workerParameters: WorkerParameters - ): ListenableWorker? { - val key = try { - Class.forName(workerClassName) - } catch (e: ClassNotFoundException) { - // Class might be missing if it was renamed / moved to a different package, as - // periodic work requests from before the rename might still exist. Catch and - // return null, which should stop future requests. - Log.d(TAG, "Invalid class: $workerClassName", e) - null - } - workerFactories[key]?.let { - return it.get().createWorker(appContext, workerParameters) - } - return null - } - - companion object { - private const val TAG = "WorkerFactory" - } -} diff --git a/app/src/main/res/color/color_background_transparent_60.xml b/app/src/main/res/color/color_background_transparent_60.xml index 0a09f2aa3..d62f447b5 100644 --- a/app/src/main/res/color/color_background_transparent_60.xml +++ b/app/src/main/res/color/color_background_transparent_60.xml @@ -1,4 +1,4 @@ - - \ No newline at end of file + + diff --git a/app/src/main/res/color/compound_button_color.xml b/app/src/main/res/color/compound_button_color.xml index a02dcfc3e..f8dbe906d 100644 --- a/app/src/main/res/color/compound_button_color.xml +++ b/app/src/main/res/color/compound_button_color.xml @@ -1,6 +1,5 @@ - - - - \ No newline at end of file + + + diff --git a/app/src/main/res/color/selectable_chip_background.xml b/app/src/main/res/color/selectable_chip_background.xml new file mode 100644 index 000000000..2469cdac5 --- /dev/null +++ b/app/src/main/res/color/selectable_chip_background.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/color/text_input_layout_box_stroke_color.xml b/app/src/main/res/color/text_input_layout_box_stroke_color.xml index d924f1e5e..3f808bd22 100644 --- a/app/src/main/res/color/text_input_layout_box_stroke_color.xml +++ b/app/src/main/res/color/text_input_layout_box_stroke_color.xml @@ -1,5 +1,5 @@ - - \ No newline at end of file + + diff --git a/app/src/main/res/drawable/background_circle.xml b/app/src/main/res/drawable/background_circle.xml deleted file mode 100644 index 923aaee68..000000000 --- a/app/src/main/res/drawable/background_circle.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/background_dialog_activity.xml b/app/src/main/res/drawable/background_dialog_activity.xml index 80cff382e..732502d4e 100644 --- a/app/src/main/res/drawable/background_dialog_activity.xml +++ b/app/src/main/res/drawable/background_dialog_activity.xml @@ -1,5 +1,9 @@ - - - - \ No newline at end of file + + + + + + diff --git a/app/src/main/res/drawable/card_frame.xml b/app/src/main/res/drawable/badge_background.xml similarity index 60% rename from app/src/main/res/drawable/card_frame.xml rename to app/src/main/res/drawable/badge_background.xml index 525731b64..96bc6bfbd 100644 --- a/app/src/main/res/drawable/card_frame.xml +++ b/app/src/main/res/drawable/badge_background.xml @@ -1,5 +1,6 @@ - + - - \ No newline at end of file + + diff --git a/app/src/main/res/drawable/dialog_background.xml b/app/src/main/res/drawable/dialog_background.xml new file mode 100644 index 000000000..bde2b56d3 --- /dev/null +++ b/app/src/main/res/drawable/dialog_background.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/heart_broken_24.xml b/app/src/main/res/drawable/heart_broken_24.xml new file mode 100644 index 000000000..bf5c73062 --- /dev/null +++ b/app/src/main/res/drawable/heart_broken_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/help_24dp.xml b/app/src/main/res/drawable/help_24dp.xml new file mode 100644 index 000000000..7f116ea42 --- /dev/null +++ b/app/src/main/res/drawable/help_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/help_message_background.xml b/app/src/main/res/drawable/help_message_background.xml deleted file mode 100644 index cb28b7987..000000000 --- a/app/src/main/res/drawable/help_message_background.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml deleted file mode 100644 index 89d18543f..000000000 --- a/app/src/main/res/drawable/ic_arrow_back.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_arrow_back_with_background.xml b/app/src/main/res/drawable/ic_arrow_back_with_background.xml index 1a4d711f0..5253e68ff 100644 --- a/app/src/main/res/drawable/ic_arrow_back_with_background.xml +++ b/app/src/main/res/drawable/ic_arrow_back_with_background.xml @@ -1,12 +1,13 @@ - - - - - - - \ No newline at end of file + + + + diff --git a/app/src/main/res/drawable/ic_at_18dp.xml b/app/src/main/res/drawable/ic_at_18dp.xml new file mode 100644 index 000000000..ba41e91d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_at_18dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_circle.xml b/app/src/main/res/drawable/ic_check_circle.xml index 3ae6f242c..602455078 100644 --- a/app/src/main/res/drawable/ic_check_circle.xml +++ b/app/src/main/res/drawable/ic_check_circle.xml @@ -4,5 +4,5 @@ android:width="18dp" android:viewportWidth="24" android:viewportHeight="24"> - - \ No newline at end of file + + diff --git a/app/src/main/res/drawable/ic_flag_24dp.xml b/app/src/main/res/drawable/ic_flag_24dp.xml index d101077ce..03df9261d 100644 --- a/app/src/main/res/drawable/ic_flag_24dp.xml +++ b/app/src/main/res/drawable/ic_flag_24dp.xml @@ -3,7 +3,7 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24" - android:tint="@color/chinwag_green"> + android:tint="?attr/colorPrimary"> diff --git a/app/src/main/res/drawable/ic_gavel_24dp.xml b/app/src/main/res/drawable/ic_gavel_24dp.xml new file mode 100644 index 000000000..674603600 --- /dev/null +++ b/app/src/main/res/drawable/ic_gavel_24dp.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_link.xml b/app/src/main/res/drawable/ic_link.xml index 5bb6b6862..94455ebed 100644 --- a/app/src/main/res/drawable/ic_link.xml +++ b/app/src/main/res/drawable/ic_link.xml @@ -4,6 +4,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_more.xml b/app/src/main/res/drawable/ic_more.xml deleted file mode 100644 index f84f9b3f1..000000000 --- a/app/src/main/res/drawable/ic_more.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_more_with_background.xml b/app/src/main/res/drawable/ic_more_with_background.xml index 755b37298..72140f5a0 100644 --- a/app/src/main/res/drawable/ic_more_with_background.xml +++ b/app/src/main/res/drawable/ic_more_with_background.xml @@ -1,12 +1,12 @@ - - - - - - - \ No newline at end of file + + + + diff --git a/app/src/main/res/drawable/ic_person_add_24dp.xml b/app/src/main/res/drawable/ic_person_add_24dp.xml index 31139ae3f..b42eb2f44 100644 --- a/app/src/main/res/drawable/ic_person_add_24dp.xml +++ b/app/src/main/res/drawable/ic_person_add_24dp.xml @@ -4,6 +4,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/drawable/ic_play_indicator.xml b/app/src/main/res/drawable/ic_play_indicator.xml index 986d3fc7b..451e51ec1 100644 --- a/app/src/main/res/drawable/ic_play_indicator.xml +++ b/app/src/main/res/drawable/ic_play_indicator.xml @@ -4,5 +4,5 @@ android:pathData="M21.282,12A9.282,9.282 0,0 1,12 21.282,9.282 9.282,0 0,1 2.718,12 9.282,9.282 0,0 1,12 2.718,9.282 9.282,0 0,1 21.282,12Z" android:strokeAlpha="1" android:strokeColor="#00000000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="8"/> - + diff --git a/app/src/main/res/drawable/ic_poll_24dp.xml b/app/src/main/res/drawable/ic_poll_24dp.xml index c68bb746d..dd0c4abe9 100644 --- a/app/src/main/res/drawable/ic_poll_24dp.xml +++ b/app/src/main/res/drawable/ic_poll_24dp.xml @@ -4,6 +4,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_reblog_active_24dp.xml b/app/src/main/res/drawable/ic_reblog_active_24dp.xml index 4ceb441e7..48d987fe9 100644 --- a/app/src/main/res/drawable/ic_reblog_active_24dp.xml +++ b/app/src/main/res/drawable/ic_reblog_active_24dp.xml @@ -4,6 +4,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_reblog_private_active_24dp.xml b/app/src/main/res/drawable/ic_reblog_private_active_24dp.xml index 2b1cd4ea9..48b7351c8 100644 --- a/app/src/main/res/drawable/ic_reblog_private_active_24dp.xml +++ b/app/src/main/res/drawable/ic_reblog_private_active_24dp.xml @@ -4,6 +4,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/drawable/ic_reply_18dp.xml b/app/src/main/res/drawable/ic_reply_18dp.xml new file mode 100644 index 000000000..234bc07dc --- /dev/null +++ b/app/src/main/res/drawable/ic_reply_18dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_reply_all_24dp.xml b/app/src/main/res/drawable/ic_reply_all_24dp.xml index 9da31f037..7da7c05fe 100644 --- a/app/src/main/res/drawable/ic_reply_all_24dp.xml +++ b/app/src/main/res/drawable/ic_reply_all_24dp.xml @@ -5,6 +5,6 @@ android:viewportHeight="24.0" android:viewportWidth="24.0"> diff --git a/app/src/main/res/drawable/play_indicator_overlay.xml b/app/src/main/res/drawable/play_indicator_overlay.xml deleted file mode 100644 index 66ffc2c9b..000000000 --- a/app/src/main/res/drawable/play_indicator_overlay.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/report_success_background.xml b/app/src/main/res/drawable/report_success_background.xml index a26c30b40..f1685bcc3 100644 --- a/app/src/main/res/drawable/report_success_background.xml +++ b/app/src/main/res/drawable/report_success_background.xml @@ -1,4 +1,4 @@ - - \ No newline at end of file + + diff --git a/app/src/main/res/drawable/tab_indicator_bottom.xml b/app/src/main/res/drawable/tab_indicator_bottom.xml new file mode 100644 index 000000000..0db28ec28 --- /dev/null +++ b/app/src/main/res/drawable/tab_indicator_bottom.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/tab_indicator_top.xml b/app/src/main/res/drawable/tab_indicator_top.xml new file mode 100644 index 000000000..74973260c --- /dev/null +++ b/app/src/main/res/drawable/tab_indicator_top.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout-land/fragment_report_done.xml b/app/src/main/res/layout-land/fragment_report_done.xml index d9900a2a8..63174148d 100644 --- a/app/src/main/res/layout-land/fragment_report_done.xml +++ b/app/src/main/res/layout-land/fragment_report_done.xml @@ -23,13 +23,13 @@ android:layout_width="0dp" android:layout_height="0dp" android:scaleType="fitCenter" - android:src="@drawable/ic_check_24dp" app:layout_constraintBottom_toBottomOf="@id/checkMark" app:layout_constraintEnd_toEndOf="@id/checkMark" + app:layout_constraintHeight_default="percent" app:layout_constraintHeight_percent="0.25" app:layout_constraintStart_toStartOf="@id/checkMark" app:layout_constraintTop_toTopOf="@id/checkMark" - app:layout_constraintHeight_default="percent" + app:srcCompat="@drawable/ic_check_24dp" tools:ignore="ContentDescription" /> - - - \ No newline at end of file + diff --git a/app/src/main/res/layout-land/item_trending_cell.xml b/app/src/main/res/layout-land/item_trending_cell.xml index 81cf6c3f4..fe373a850 100644 --- a/app/src/main/res/layout-land/item_trending_cell.xml +++ b/app/src/main/res/layout-land/item_trending_cell.xml @@ -25,10 +25,10 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:lineWidth="2sp" - app:metaColor="@color/chinwag_green_light" - app:primaryLineColor="@color/chinwag_green" + app:metaColor="?android:attr/textColorTertiary" + app:primaryLineColor="?attr/colorPrimary" app:proportionalTrending="true" - app:secondaryLineColor="@color/tusky_red" /> + app:secondaryLineColor="@color/warning_color" /> + diff --git a/app/src/main/res/layout-sw640dp/fragment_timeline.xml b/app/src/main/res/layout-sw640dp/fragment_timeline.xml index af49280d9..19a95b21e 100644 --- a/app/src/main/res/layout-sw640dp/fragment_timeline.xml +++ b/app/src/main/res/layout-sw640dp/fragment_timeline.xml @@ -12,44 +12,37 @@ android:layout_gravity="center_horizontal" android:background="?android:attr/colorBackground"> - - - + android:layout_height="match_parent" + android:clipToPadding="false" /> - + - - - + - + + - @@ -461,7 +473,8 @@ android:layout_gravity="bottom|end" android:layout_margin="16dp" android:contentDescription="@string/action_mention" - app:srcCompat="@drawable/ic_create_24dp" /> + app:srcCompat="@drawable/ic_create_24dp" + app:tint="?attr/colorOnPrimary" /> @@ -479,4 +492,4 @@ - + diff --git a/app/src/main/res/layout/activity_announcements.xml b/app/src/main/res/layout/activity_announcements.xml index af3158b47..1094d5e0e 100644 --- a/app/src/main/res/layout/activity_announcements.xml +++ b/app/src/main/res/layout/activity_announcements.xml @@ -9,13 +9,14 @@ android:id="@+id/includedToolbar" layout="@layout/toolbar_basic" /> - + android:layout_gravity="center" + android:indeterminate="true" /> - + android:layout_height="match_parent" + android:clipToPadding="false" + android:paddingBottom="@dimen/recyclerview_bottom_padding_no_actionbutton" /> - + diff --git a/app/src/main/res/layout/activity_compose.xml b/app/src/main/res/layout/activity_compose.xml index 7e3bae8b3..c3cfc3e1f 100644 --- a/app/src/main/res/layout/activity_compose.xml +++ b/app/src/main/res/layout/activity_compose.xml @@ -6,79 +6,88 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - + android:background="@android:color/transparent" + android:fitsSystemWindows="true"> - - + - + + - + - + - - + + + + + + + + android:layout_height="match_parent" + android:layout_marginBottom="@dimen/compose_bottom_bar_height" + android:clipToPadding="false" + android:paddingTop="8dp" + android:paddingBottom="8dp" + app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"> @@ -234,6 +245,7 @@ android:padding="8dp" android:text="@string/action_add_poll" android:textSize="?attr/status_text_medium" /> + @@ -259,7 +271,7 @@ android:paddingStart="24dp" android:paddingTop="12dp" android:paddingEnd="24dp" - android:paddingBottom="60dp" + android:paddingBottom="@dimen/compose_bottom_bar_height" app:behavior_hideable="true" app:behavior_peekHeight="0dp" app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" /> @@ -273,7 +285,7 @@ android:paddingStart="16dp" android:paddingTop="8dp" android:paddingEnd="16dp" - android:paddingBottom="52dp" + android:paddingBottom="@dimen/compose_bottom_bar_height" app:behavior_hideable="true" app:behavior_peekHeight="0dp" app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" /> @@ -283,14 +295,13 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" - android:animateLayoutChanges="true" android:background="?attr/colorSurface" android:elevation="12dp" android:gravity="center_vertical" - android:paddingStart="8dp" - android:paddingTop="4dp" - android:paddingEnd="8dp" - android:paddingBottom="4dp"> + android:paddingStart="@dimen/compose_bottom_bar_padding_horizontal" + android:paddingTop="@dimen/compose_bottom_bar_padding_vertical" + android:paddingEnd="@dimen/compose_bottom_bar_padding_horizontal" + android:paddingBottom="@dimen/compose_bottom_bar_padding_vertical"> @@ -379,7 +389,8 @@ android:layout_width="@dimen/toot_button_width" android:layout_height="wrap_content" android:layout_marginStart="10dp" - android:textSize="?attr/status_text_medium" /> + android:textSize="?attr/status_text_medium" + app:iconGravity="textStart" /> diff --git a/app/src/main/res/layout/activity_drafts.xml b/app/src/main/res/layout/activity_drafts.xml index 632d19bb1..41cf70500 100644 --- a/app/src/main/res/layout/activity_drafts.xml +++ b/app/src/main/res/layout/activity_drafts.xml @@ -1,7 +1,7 @@ @@ -14,16 +14,20 @@ android:id="@+id/draftsRecyclerView" android:layout_width="match_parent" android:layout_height="match_parent" - app:layout_behavior="@string/appbar_scrolling_view_behavior"/> + android:clipToPadding="false" + android:paddingBottom="@dimen/recyclerview_bottom_padding_no_actionbutton" + android:scrollbarStyle="outsideInset" + android:scrollbars="vertical" + app:layout_behavior="@string/appbar_scrolling_view_behavior" /> diff --git a/app/src/main/res/layout/activity_edit_filter.xml b/app/src/main/res/layout/activity_edit_filter.xml index 221a8b6af..2b104ab68 100644 --- a/app/src/main/res/layout/activity_edit_filter.xml +++ b/app/src/main/res/layout/activity_edit_filter.xml @@ -11,8 +11,10 @@ layout="@layout/toolbar_basic" /> + + android:inputType="textNoSuggestions" /> + + android:id="@+id/keywordChips" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + - + + android:text="@string/filter_description_warn" /> + + android:text="@string/filter_description_hide" /> + - + android:hint="@string/label_expires_after"> - + + + - - - - - + android:paddingStart="?android:attr/listPreferredItemPaddingStart" + android:paddingEnd="?android:attr/listPreferredItemPaddingEnd">