diff --git a/.github/ci-gradle.properties b/.github/ci-gradle.properties new file mode 100644 index 000000000..b27d29785 --- /dev/null +++ b/.github/ci-gradle.properties @@ -0,0 +1,24 @@ +# +# 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 . +# + +# CI build workers are ephemeral, so don't benefit from the Gradle daemon +org.gradle.daemon=false +org.gradle.parallel=true +org.gradle.workers.max=2 + +kotlin.incremental=false +kotlin.compiler.execution.strategy=in-process diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..63afec2db --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + tags: + - '*' + pull_request: + workflow_dispatch: + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Gradle Wrapper Validation + uses: gradle/wrapper-validation-action@v1 + + - 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@v2 + 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/ktlint.yml b/.github/workflows/ktlint.yml new file mode 100644 index 000000000..c7c0e4241 --- /dev/null +++ b/.github/workflows/ktlint.yml @@ -0,0 +1,35 @@ +name: reviewdog-suggester +on: pull_request +jobs: + ktlint: + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: '17' + cache: 'gradle' + + - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + + - uses: gradle/wrapper-validation-action@v1 + + - uses: gradle/gradle-build-action@v2 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} + + - run: chmod +x ./gradlew + + - run: ./gradlew ktlintFormat + + - uses: reviewdog/action-suggester@v1 + with: + tool_name: ktlintFormat + +permissions: + contents: read + issues: write + pull-requests: write diff --git a/.github/workflows/populate-gradle-build-cache.yml b/.github/workflows/populate-gradle-build-cache.yml new file mode 100644 index 000000000..4eeb8dd0c --- /dev/null +++ b/.github/workflows/populate-gradle-build-cache.yml @@ -0,0 +1,34 @@ +# 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@v3 + + - uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Gradle Wrapper Validation + uses: gradle/wrapper-validation-action@v1 + + - name: Copy CI gradle.properties + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + + - uses: gradle/gradle-build-action@v2 + 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/CHANGELOG.md b/CHANGELOG.md index 1ea9aae4a..1328cd7f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ ### Significant bug fixes +## v23.0 + +### New features and other improvements + +- **New preference to scale UI text**, [PR#3248](https://github.com/tuskyapp/Tusky/pull/3248) by [@nikclayton](https://mastodon.social/@nikclayton) + +### Significant bug fixes + +- **Save account information correctly**, [PR#3720](https://github.com/tuskyapp/Tusky/pull/3720) by [@connyduck](https://chaos.social/@ConnyDuck) + - If you were logged in with multiple accounts it was possible to switch accounts in a way that the UI showed the new account, but database operations were happening using the old account. +- **"pull" notifications on devices running Android versions <= 11**, [PR#3649](https://github.com/tuskyapp/Tusky/pull/3649) by [@nikclayton](https://mastodon.social/@nikclayton) + - Pull notifications (i.e., not using ntfy.sh) could silently fail on devices running Android 11 and below +- **Work around Android bug where text fields could "forget" they can copy/paste**, [PR#3707](https://github.com/tuskyapp/Tusky/pull/3707) by [@nikclayton](https://mastodon.social/@nikclayton) +- **Viewing "diffs" in edit history will not extend off screen edge**, [PR#3431](https://github.com/tuskyapp/Tusky/pull/3431) by [@nikclayton](https://mastodon.social/@nikclayton) +- **Don't crash if your server has no post edit history**, [PR#3747](https://github.com/tuskyapp/Tusky/pull/3747) by [@nikclayton](https://mastodon.social/@nikclayton) + - Your Mastodon server might know that a post has been edited, but not know the details of those edits. Trying to view the history of those statuses no longer crashes. +- **Add a "Delete" button when editing a filter**, [PR#3553](https://github.com/tuskyapp/Tusky/pull/3553) by [@Tak](https://mastodon.gamedev.place/@Tak) +- **Show non-square emoji correctly**, [PR#3711](https://github.com/tuskyapp/Tusky/pull/3711) by [@connyduck](https://chaos.social/@ConnyDuck) +- **Potential crash when editing profile fields**, [PR#3808](https://github.com/tuskyapp/Tusky/pull/3808) by [@nikclayton](https://mastodon.social/@nikclayton) +- **Oversized context menu when editing image descriptions**, [PR#3787](https://github.com/tuskyapp/Tusky/pull/3787) by [@connyduck](https://chaos.social/@ConnyDuck) + +## v23.0 beta 2 + +### Significant bug fixes + +- **Potential crash when editing profile fields**, [PR#3808](https://github.com/tuskyapp/Tusky/pull/3808) by [@nikclayton](https://mastodon.social/@nikclayton) +- **Oversized context menu when editing image descriptions**, [PR#3787](https://github.com/tuskyapp/Tusky/pull/3787) by [@connyduck](https://chaos.social/@ConnyDuck) + ## v23.0 beta 1 ### New features and other improvements diff --git a/README.md b/README.md index 5bc9b63bc..62c5e1ded 100644 --- a/README.md +++ b/README.md @@ -32,4 +32,4 @@ If you have any bug reports, feature requests or questions please open an issue We always welcome new contributors! Please read our [contribution guide](https://github.com/tuskyapp/Tusky/blob/develop/CONTRIBUTING.md) to get started. ### Development chatroom -https://riot.im/app/#/room/#Tusky:matrix.org +https://matrix.to/#/#Tusky:matrix.org diff --git a/app/build.gradle b/app/build.gradle index 7196c6269..3c0702d3e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -29,8 +29,8 @@ android { namespace "com.keylesspalace.tusky" minSdk 23 targetSdk 33 - versionCode 111 - versionName "23.0 beta 1" + versionCode 113 + versionName "23.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true @@ -60,8 +60,7 @@ android { lint { lintConfig file("lint.xml") - // Regenerate by deleting app/lint-baseline.xml, then run: - // ./gradlew lintBlueDebug + // Regenerate by running `./gradlew app:newLintBaseline` baseline = file("lint-baseline.xml") } @@ -103,8 +102,8 @@ android { // Can remove this once https://issuetracker.google.com/issues/260059413 is fixed. // https://kotlinlang.org/docs/gradle-configure-project.html#gradle-java-toolchains-support compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } applicationVariants.configureEach { variant -> variant.outputs.configureEach { @@ -147,7 +146,7 @@ dependencies { implementation libs.conscrypt.android implementation libs.bundles.glide - kapt libs.glide.compiler + ksp libs.glide.compiler implementation libs.bundles.rxjava3 @@ -158,7 +157,7 @@ dependencies { implementation libs.sparkbutton - implementation libs.photoview + implementation libs.touchimageview implementation libs.bundles.material.drawer implementation libs.material.typeface @@ -186,3 +185,35 @@ dependencies { 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) { + 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"]) +} + +tasks.register("newLintBaseline") { + description 'Deletes and then recreates the lint baseline' + + // This task should always run, irrespective of caching + notCompatibleWithConfigurationCache("Is always out of date") + outputs.upToDateWhen { false } + + doLast { + delete android.lint.baseline.path + } + + // Regenerate the lint baseline + it.finalizedBy tasks.named("lintBlueDebug") +} diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 76e26c38f..fb5904550 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -1,5 +1,5 @@ - + @@ -30,7 +30,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -41,7 +41,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -80,10 +80,21 @@ errorLine2=" ~~~~~~~"> + + + + - - - - @@ -333,7 +333,7 @@ errorLine2=" ^"> @@ -355,7 +355,7 @@ errorLine2=" ^"> @@ -366,7 +366,7 @@ errorLine2=" ^"> @@ -388,7 +388,7 @@ errorLine2=" ^"> @@ -410,7 +410,7 @@ errorLine2=" ^"> @@ -421,7 +421,7 @@ errorLine2=" ^"> @@ -432,7 +432,7 @@ errorLine2=" ^"> @@ -443,7 +443,7 @@ errorLine2=" ^"> @@ -454,7 +454,7 @@ errorLine2=" ^"> @@ -465,7 +465,7 @@ errorLine2=" ^"> @@ -476,7 +476,7 @@ errorLine2=" ^"> @@ -487,7 +487,7 @@ errorLine2=" ^"> @@ -498,7 +498,7 @@ errorLine2=" ^"> @@ -509,7 +509,7 @@ errorLine2=" ^"> @@ -520,7 +520,7 @@ errorLine2=" ^"> @@ -531,7 +531,7 @@ errorLine2=" ^"> @@ -542,7 +542,7 @@ errorLine2=" ^"> @@ -553,7 +553,7 @@ errorLine2=" ^"> @@ -751,7 +751,7 @@ errorLine2=" ^"> @@ -762,7 +762,7 @@ errorLine2=" ^"> @@ -773,7 +773,7 @@ errorLine2=" ^"> @@ -784,10 +784,21 @@ errorLine2=" ^"> + + + + @@ -817,7 +828,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -828,7 +839,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -839,7 +850,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -850,7 +861,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -865,534 +876,6 @@ column="5"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1668,61 +1151,6 @@ column="27"/> - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + @@ -1861,7 +1344,7 @@ errorLine1=" android:pathData="M405.28,504.42c-2.15,-0.07 -4.6,-2.17 -6.58,-4.97 -7.06,-9.93 -10.6,-18.1 -16.95,-28.33 -2,-3.2 -0.6,-6.08 2.34,-7.93 4.3,-2.72 3.24,-4.92 -0.2,-7.4 -5.8,-4.15 -10.93,-9.07 -16.15,-13.93 -5.18,-4.84 -6.54,-10.5 -4.56,-16.73 2.86,-9 6.4,-17.82 9.8,-26.67 1.15,-3.04 2.94,-4.28 6.62,-2.8 10.5,4.2 21.18,7.97 31.83,11.8 4.2,1.52 5.05,3.54 2.2,7.32 -2.97,3.95 -7.53,8.24 -7.6,12.46 -0.1,4.9 7.12,5.92 10.9,8.96 4.64,3.74 6.55,7.12 4.83,13.24 -4.62,16.42 -8.58,33.02 -12.82,49.55 -0.58,2.26 -0.84,4.82 -3.68,5.42zM471.96,422.6c17.67,1.02 17.95,-0.12 14.5,17.04 -1.83,9.04 -3.97,18.03 -5.53,27.1 -0.8,4.7 -3.9,8.87 -8.54,7.78 -10.8,-2.54 -12.4,-6.2 -19.87,10.08 -1.28,2.8 -3.26,12.98 -10,12.97 -3.64,0 -4.74,-3.66 -5.3,-6.76 -1.42,-7.67 -3.84,-25 -4.05,-25.9 -2.27,-9.63 -3.25,-11.82 8.96,-14.76 3.24,-0.78 3.46,-2.55 3.32,-5.1 -0.27,-4.73 -0.4,-9.46 -0.73,-14.18 -0.3,-4.47 1.86,-6.08 6.1,-6.86 7.96,-1.47 14.5,-1.8 21.13,-1.4zM384.9,509.3c-0.2,1.8 0.3,3.7 -1.74,4.45 -1.47,0.52 -2.74,-0.13 -3.86,-1.16 -0.87,-0.8 -1.8,-1.53 -2.74,-2.26 -3.17,-2.5 -6.08,-6.43 -9.6,-7.08 -4.37,-0.8 -5.5,4.9 -8.05,7.73 -3.36,3.76 -6,1.15 -8.5,-0.84 -5.84,-4.68 -8.92,-11.6 -13.57,-17.3 -4.62,-5.66 -9.26,-11.4 -12.23,-18.28 -1.5,-3.48 -1.55,-6.08 1.2,-8.93 5.18,-5.4 11.7,-8.85 18.06,-12.46 3.04,-1.74 6.48,-0.93 8.03,3.2 0.82,2.2 2.07,4.25 3.13,6.36 1.7,3.36 2.58,8.2 5.3,9.66 2.7,1.46 5.6,-3.3 8.74,-4.76 4.2,-1.97 6.78,-0.52 8.22,3.65 4.24,12.4 5.43,25.4 7.6,38z" />" errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1872,7 +1355,7 @@ errorLine1=" android:pathData="M309.5,413.94c-3.87,0.1 -6.45,2.94 -9.34,4.83 -17.03,11.15 -35.34,18.28 -55.9,18.72 -5.75,0.12 -11.5,-0.2 -17.05,-2.1 -2.56,-0.88 -3.83,-2.15 -3.78,-5 0.13,-6.13 -1.2,-12.08 -2.72,-17.97 -0.45,-1.77 -1.34,-3.48 -2.3,-5.06 -0.6,-1.02 -1.66,-2.07 -3.1,-1.24 -1.33,0.77 -1.75,1.8 -1.3,3.52 2.83,10.93 1.48,21.9 0.12,32.87 -0.2,1.65 -1.24,2.38 -2.65,2.9 -7.22,2.65 -13.3,6.32 -13.45,15.24 -0.02,0.84 -0.34,1.5 -1.3,1.66 -1.1,0.16 -1.74,-0.42 -2.22,-1.3 -1.18,-2.2 -2.24,-4.44 -3.94,-6.33 -3.93,-4.36 -6.72,-4.9 -11.86,-2.13 -2.92,1.6 -5.4,3.62 -6.9,6.73 -0.58,1.24 -0.52,3.34 -2.9,2.95 -2.17,-0.36 -4.15,-0.6 -5.06,-3.27 -0.84,-2.44 -1.6,-4.95 -3.57,-7 -6.6,-6.84 -12.28,-7.2 -19.33,-0.9 -3.77,3.35 -4.7,3.26 -7.7,-0.93 -1.47,-2.07 -2.2,-4.4 -2.3,-6.98 -0.5,-13.3 0.3,-26.45 5.03,-39.1 0.8,-2.14 -3.3,-6.3 -8.4,-10.03 -9.7,-7.05 -19.72,-18.28 -25.2,-30.94 -1.97,-4.54 -3.15,-13.82 -5.52,-13.86 -1.5,-0.03 -3,6.1 -4.4,8.74 -5.24,9.93 -15.64,13.7 -24.34,19.95 -2.4,1.73 -4.68,0.87 -6.15,-1.87 -2.7,-5.04 -1.26,-9 3.44,-12.1 13.66,-8.98 21.7,-21.55 22.36,-38.14 0.8,-20.28 2.94,-40.34 8.24,-60 6.3,-23.35 16.82,-44.46 32.98,-62.64 4.75,-5.34 8.53,-11.54 14.2,-16.1 1.77,-1.43 3.6,-2.8 6.25,-4.88 -0.8,5.58 -1.58,10.1 -2.08,14.62 -0.84,7.7 3.3,13.64 10.76,14.8 3.47,0.55 3.67,1.72 3.45,4.85 -0.62,8.83 0.8,17.48 8.52,22.98 7.26,5.17 15.44,6.67 23.63,1.6 2.48,-1.52 2.93,0.27 3.74,1.73 6.48,11.82 15.54,21.3 26.44,29.1 0.63,0.45 1.17,1.1 1.86,1.4 2.4,1.05 4.56,-0.35 5.75,-1.8 1.13,-1.4 -0.93,-2.7 -2.18,-3.52 -11,-7.3 -18.04,-17.88 -24.32,-29.1 -2.2,-3.97 -3.03,-7.74 -0.56,-12.28 6.58,-12.12 9.08,-25.38 9.57,-39.06 0.04,-0.93 0.1,-1.88 -0.04,-2.8 -0.18,-1.3 -0.8,-2.5 -2.22,-2.63 -1.25,-0.13 -1.94,0.87 -2.28,1.97 -0.5,1.63 -1.17,3.26 -1.33,4.94 -1.3,14.1 -6.95,26.77 -13.2,39.16 -4.6,9.12 -13.63,11.57 -20.37,6.05 -3.15,-2.58 -4,-6.26 -4.68,-9.9 -0.7,-3.82 -1.34,-7.73 -1.24,-11.58 0.13,-4.95 -2.83,-6.93 -6.73,-7.34 -5.1,-0.53 -6.52,-3.4 -6.6,-7.84 -0.13,-7.76 2.36,-15.05 4.18,-22.43 1.62,-6.58 1.52,-7.28 -4.45,-10.3 -3.03,-1.53 -1.86,-4.2 -1.12,-6.13 1.78,-4.64 3.63,-9.36 6.26,-13.55 7.02,-11.2 14.6,-21.97 24.63,-30.9 16.75,-14.9 33.9,-29.3 53.54,-40.22 6.14,-3.4 12.3,-6.86 18.56,-10.1 2.4,-1.23 1.5,-3.07 1.22,-4.85 -1.08,-7.2 -3.98,-14.1 -2.87,-21.62 2.04,-13.8 10.8,-18.3 22.52,-16 7.66,1.53 11.43,9.06 14.18,16.2 0.8,2.1 0.36,5.04 3.28,5.63 3.02,0.6 4.84,-1.44 6.45,-3.72 3.27,-4.64 7.42,-8.46 12.2,-11.44 6.52,-4.06 13.5,-3.1 20.26,-0.7 3.2,1.13 4,6.4 1.52,9.78 -2.07,2.82 -3.33,5.92 -4.7,9.04 -1.56,3.62 -0.66,5.12 3.37,4.33 7.17,-1.4 14.35,-2.44 21.63,-3.2 5.6,-0.6 10.22,1.23 14.48,4.06 4.25,2.85 2.3,13.23 -2.84,16.8 -5.27,3.67 -5.47,5.68 -0.85,9.95 9.53,8.8 18.57,17.83 23.7,30.2 2.97,7.22 3.93,14.72 5.15,22.2 1.8,11.08 2.67,22.32 1.86,33.52 -0.4,5.8 -0.65,11.78 -2.6,17.4 -1.67,4.87 -0.9,9.9 -0.96,14.84 -0.22,17.26 -1.04,34.42 -3.84,51.54 -2.85,17.4 -6.84,34.4 -11.87,51.3 -5.1,17 -6.27,34.65 -4.46,52.25 0.83,8.02 2.4,16.42 9.23,22.3 0.8,0.7 1.22,1.8 1.76,2.74 1.26,2.2 3.94,4.33 1.67,7.03 -2.07,2.46 -5.18,1.78 -7.9,1.07 -1.45,-0.37 -2.75,-1.38 -4.06,-2.2 -4.9,-3.05 -10.4,-2.58 -14.56,1.34 -1.25,1.17 -2.4,2.46 -3.66,3.6 -1.43,1.3 -2.72,3.8 -4.93,2.56 -1.94,-1.1 -0.64,-3.44 -0.32,-5.2 0.43,-2.27 -0.6,-3.9 -1.94,-5.5 -2.5,-2.96 -4.73,-6.28 -7.65,-8.78 -7,-5.97 -11.13,-13.77 -14.66,-21.9 -3.86,-8.86 -10.1,-16.45 -13.13,-26.13 5.28,-0.78 10.92,-1.33 16.44,-2.48 13.84,-2.9 24.47,-9.7 28.55,-24.22 1.2,-4.32 1.2,-8.65 0.53,-12.96 -0.8,-5.2 -6.34,-8.24 -11.46,-7.1 -13.87,3.05 -27.7,5.82 -41.58,-0.6 -8.8,-4.05 -12.52,-12.2 -17.24,-19.57 -1.2,-1.86 1.15,-3.64 2.17,-5.33 5.57,-9.25 -0.1,-21.56 -10.14,-21.93 -1.08,-0.04 -2.16,0.18 -3.23,0.35 -2.1,0.32 -3.64,1.54 -3.57,3.65 0.08,2.65 2.33,1.85 3.93,1.88 6.58,0.1 8.67,2.55 7.87,9.66 -7.32,-7.13 -14.85,-4.88 -22.26,-0.57 -10.73,6.25 -18.93,14.95 -17.3,28.02 1.53,12.18 7.25,23.1 16.8,31.52 8.72,7.7 18.46,13.6 29.6,17.1 3.44,1.1 7.03,1.16 10.58,1.55 2.46,0.27 3.95,1.04 4.84,3.9 1.32,4.27 3.43,8.4 5.82,12.2 4.5,7.13 8.5,14.5 11.38,22.4 2.88,7.9 8.38,13.7 14.3,19.27 1.56,2.64 0.93,5.4 -0.7,7.44 -7.38,9.28 -14.28,19.06 -24.28,25.93 -1.6,1.1 -3.22,1.76 -5.17,1.47z" />" errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1883,7 +1366,7 @@ errorLine1=" android:pathData="M312.62,450.27c-2.06,-0.46 -5.12,0.7 -5.7,-1.1 -0.82,-2.5 2.33,-3.64 4.03,-5 3.94,-3.15 6.66,-2.98 8.7,0.15 2.13,3.22 1.33,5.05 -2.53,5.63 -1.53,0.23 -3.1,0.22 -4.5,0.32zM157.95,460.62c-5.3,-0.4 -9.72,-1.5 -14.06,-2.98 -2.02,-0.67 -1.86,-1.5 -0.45,-2.87 4.25,-4.1 8.5,-3.86 11.94,0.94 0.94,1.33 1.52,2.9 2.55,4.92zM174.74,463.44c1.64,-3.02 2.93,-4.8 5.17,-5.88 4.1,-1.94 7.56,-0.25 8.52,4.18 0.42,1.9 -0.87,1.67 -1.87,1.68 -3.67,0.06 -7.34,0.02 -11.8,0.02zM336.16,448.93c1.17,-5.2 4.6,-7.28 9.25,-7.9 0.8,-0.1 1.53,0.08 2.07,0.75 0.48,0.62 0.23,1.2 -0.2,1.63 -3.03,2.96 -5.6,6.8 -11.1,5.53zM391.76,359.1c-3.24,1.28 -6.07,1.93 -8.98,0.53 -0.94,-0.45 -2.2,-0.8 -1.97,-2.2 0.18,-1.06 1.22,-1.28 2.12,-1.5 3.43,-0.87 6.42,-0.48 8.84,3.18zM406.75,352.33c0,-5.14 1.85,-7.8 5.74,-8.94 0.8,-0.24 1.93,-0.62 2.46,0.5 0.4,0.8 -0.26,1.53 -0.8,2.02 -2.18,1.95 -4.42,3.83 -7.4,6.4zM209.56,454.67c-0.23,2.68 -3.84,6.76 -5.77,6.53 -1,-0.12 -1.2,-0.7 -1.16,-1.6 0.1,-1.9 4.42,-6.72 5.9,-6.55 1.04,0.12 1.15,0.78 1.02,1.62z" />" errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1894,7 +1377,7 @@ errorLine1=" android:pathData="M392.36,474.27c-1.67,-3.3 -3.37,-5.32 2.46,-9.24 4.87,-3.28 3.88,-4.96 0.05,-8.64 -6.18,-5.92 -13.7,-11.35 -19.86,-16.35 -4.12,-3.36 -5.04,-8.04 -3.25,-12.74 1.84,-4.86 3.3,-9.92 4.43,-15 0.88,-3.92 1.57,-9.7 13.2,-4.5 16.55,7.4 13.85,9.62 8.22,20.8 -3.05,6.05 -2.17,9.5 4.6,11.84 9.85,4.25 12.58,6.6 11.1,14.06 -2.53,12.68 -7.05,41.66 -9.94,41.67 -2.45,0 -7.93,-15.82 -11,-21.9zM443.2,491.03c-2.34,-0.08 -2.85,-19.2 -3.6,-25.64 -0.4,-3.54 3.07,-6.3 5.6,-6.9 10.02,-2.28 10.67,-0.48 8.92,-10.75 -0.12,-0.7 -0.34,-1.38 -0.45,-2.08 -1.47,-9.52 -1,-11.12 8.5,-12.67 16.6,-2.7 17.4,-0.98 14.57,15.65 -0.7,4.2 -1.68,8.4 -2.1,12.63 -0.45,4.57 -2.8,6.07 -7.07,5.62 -12.44,-1.3 -14.94,2.13 -18.24,11.3 -0.83,2.32 -4.22,12.9 -6.12,12.83zM380.22,506.38c-2.67,1.2 -10.35,-9.17 -13.22,-10.96 -1.65,-1.02 -5.03,-2.06 -10.26,7.06 -1.2,2.94 -2.98,1.02 -4.18,-0.6 -6.08,-8.14 -12.07,-16.36 -18.26,-24.42 -2.83,-3.67 -5.9,-6.33 2.7,-11.64 9.4,-5.8 9.5,-5.05 16.23,9.2 3.83,8.1 6.77,9.04 12.77,3.96 2.78,-2.24 4.18,-1.42 5.24,1.4 2.14,5.7 11.4,24.9 8.98,26z" />" errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1905,7 +1388,7 @@ errorLine1=" android:pathData="M210.54,346.53c-1.04,-10.87 -1.55,-21.76 -1,-32.67 0.3,-6.02 0.06,-12.27 3.43,-17.66 0.7,-1.14 0.3,-4.48 3.4,-2.9 2.4,1.2 3.74,3.16 2.36,5.73 -3.3,6.14 -2.77,12.68 -2.3,19.18 0.75,10.23 1.36,20.45 1.57,30.7 0.13,6.7 -1.3,13.33 -2.94,19.6 -1.1,4.25 -6.76,6.1 -11.14,7.7 -14.25,5.15 -28.7,4.03 -43.18,1.05 -4.4,-0.9 -8.8,-1.9 -13.13,-3.14 -12.8,-3.64 -21.36,-14.9 -22.23,-29.2 -1.33,-21.93 3.8,-42.26 15.37,-60.96 2.07,-3.34 4.55,-6.32 7.7,-8.7 1.12,-0.88 2.48,-1.9 3.7,-0.52 1.15,1.3 0.18,2.68 -0.76,3.74 -11.3,12.77 -15.1,28.64 -18.66,44.64 -2.04,9.1 -0.17,17.94 1.43,26.8 0.28,1.57 1,2.22 2.96,1.74 10.44,-2.54 18.43,3.77 19.02,14.98 0.1,2.15 -0.02,3.95 2.7,4.6 2.68,0.6 3.1,-1.24 3.93,-2.92 4.6,-9.3 13.3,-10.46 20.12,-2.64 1.32,1.5 2.54,3.24 3.26,5.08 1.04,2.65 2.88,2.18 4.92,2 2.46,-0.2 1.8,-1.85 1.96,-3.36 0.86,-7.92 7.08,-13.4 14.82,-12.84 2.32,0.17 2.73,-0.7 2.72,-2.53 0,-2.5 0,-4.98 0,-7.47zM308.06,332.2c-0.12,6.25 -2.05,10.53 -6.22,13.6 -1.52,1.1 -3.7,2.1 -5.26,1.3 -4.14,-2.1 -6.18,-0.1 -8.64,2.88 -3.26,3.96 -7.98,4.98 -12.76,3.6 -4.4,-1.26 -5.85,-5.44 -6.63,-9.4 -0.6,-3 -1.15,-3.68 -4.4,-2.7 -9.76,2.93 -14.16,-1.63 -12,-11.66 1,-4.58 2.56,-8.65 6.57,-11.36 1.13,-0.77 2.5,-1.76 3.74,-0.3 1,1.17 0.15,2.4 -0.62,3.36 -2.62,3.28 -3.73,7.05 -4.08,11.2 -0.23,2.62 0.6,3.44 3.14,2.85 2,-0.47 3.8,-1.23 5.64,-2.13 5.07,-2.5 7.27,-1.12 7.55,4.4 0.08,1.86 0.33,3.73 0.8,5.5 1.24,4.95 4.83,5.96 8.48,2.23 1.82,-1.86 3.06,-4.3 4.58,-6.44 1.9,-2.7 4,-3.1 6.62,-0.78 3.23,2.9 4.34,2.52 6.06,-1.47 0.78,-1.8 0.67,-3.66 0.7,-5.5 0,-2.2 0.3,-4.33 2.96,-4.42 2.47,-0.08 3.38,1.84 3.66,4.04 0.1,0.77 0.13,1.54 0.1,1.2z" />" errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1916,7 +1399,7 @@ errorLine1=" android:pathData="M189.06,292.78c1.26,-4.3 3.43,-8.9 6.02,-13.28 0.6,-1 1.7,-1.37 2.85,-0.8 1.37,0.66 2.28,1.83 1.88,3.35 -1.2,4.63 -2.33,9.3 -4.73,13.5 -0.8,1.4 -2.04,2.48 -3.84,1.68 -1.57,-0.7 -2.17,-2.1 -2.17,-4.45zM198.63,185.98c-0.63,4.92 -2.85,9.2 -4.9,13.5 -0.66,1.33 -2.2,1.65 -3.68,1 -1.26,-0.58 -1.86,-1.58 -1.68,-3 0.62,-4.82 2.53,-9.23 4.37,-13.64 0.58,-1.4 1.85,-2.1 3.42,-1.76 2.12,0.5 2.25,2.3 2.47,3.9zM182.57,137.72c-2.2,-0.14 -3.55,-1.7 -2.52,-4 2.05,-4.5 4.26,-9 8.27,-12.18 1.23,-0.97 2.72,-1.88 4.22,-0.62 1.46,1.23 1.03,2.8 0.14,4.2 -2.14,3.43 -4.27,6.86 -6.46,10.25 -0.75,1.17 -1.6,2.33 -3.65,2.35zM129.1,250.15c1.57,-3.46 2.86,-7.78 5.6,-11.37 0.87,-1.13 2.3,-1.77 3.74,-1 1.53,0.85 2.22,2.4 1.6,3.97 -1.57,4.02 -3.28,8 -5.15,11.87 -0.62,1.28 -2.08,2 -3.6,1.42 -1.85,-0.7 -2.23,-2.38 -2.2,-4.9zM104.77,259.68c1.02,-4.7 3.63,-8.9 6.3,-13.1 0.7,-1.1 2.08,-1.3 3.35,-0.68 1.3,0.64 1.7,1.9 1.44,3.15 -1,5.1 -3.78,9.45 -6.2,13.92 -0.62,1.14 -2.1,1.3 -3.34,0.63 -1.46,-0.77 -1.5,-2.22 -1.55,-3.92zM196.4,405.28c0.33,-2.06 -0.65,-5.15 2.86,-5.25 3,-0.1 3.44,2.43 3.8,4.9 0.45,3.2 1.3,6.35 1.77,9.56 0.22,1.56 -0.16,3.2 -2.06,3.7 -1.83,0.46 -2.9,-0.64 -3.7,-2.1 -1.82,-3.35 -2.6,-6.95 -2.66,-10.82zM179.67,166.87c0.44,-5.7 2.9,-9.64 5.6,-13.45 0.94,-1.34 2.5,-1.94 4.1,-1.02 1.45,0.83 1.62,2.32 1.07,3.68 -1.54,3.88 -3.14,7.75 -4.92,11.52 -0.7,1.5 -2.18,2.23 -3.92,1.72 -1.65,-0.48 -1.94,-1.82 -1.93,-2.45zM189.68,317.63c-0.5,3.56 -0.88,7.1 -1.57,10.57 -0.33,1.66 -1.24,3.36 -3.44,3.03 -1.94,-0.3 -2.9,-1.72 -3.07,-3.58 -0.45,-4.6 1.1,-8.85 2.43,-13.12 0.48,-1.52 1.94,-1.97 3.45,-1.5 2.4,0.72 1.8,2.93 2.2,4.6zM130.33,224.68c-2.6,-0.24 -3.46,-1.8 -2.67,-3.9 1.44,-3.83 3.92,-7.07 6.97,-9.77 1.3,-1.13 3.18,-1.62 4.7,-0.2 1.46,1.4 1.22,3.15 0.2,4.7 -1.55,2.33 -3.16,4.63 -4.87,6.84 -1.1,1.4 -2.68,2.08 -4.33,2.34zM169.37,397.9c0,-1.08 -0.06,-2.17 0.02,-3.25 0.07,-1.35 0.73,-2.4 2.1,-2.73 1.48,-0.36 2.36,0.6 2.94,1.74 1.9,3.8 2.58,7.94 2.93,12.12 0.12,1.52 -0.92,2.72 -2.54,2.78 -1.8,0.08 -3.44,-0.6 -4.08,-2.46 -0.92,-2.64 -1.56,-5.36 -1.38,-8.2zM117.86,285.05c-0.43,3.52 -2.02,6.88 -3.73,10.17 -0.63,1.2 -1.73,2.28 -3.42,1.68 -1.5,-0.54 -2.22,-1.76 -2.12,-3.26 0.3,-4.27 2.1,-8.04 4.38,-11.56 0.74,-1.13 2.13,-1.3 3.38,-0.83 1.64,0.63 1.5,2.15 1.52,3.8zM175.57,295.8c-1.7,4.22 -3.23,8.28 -5,12.25 -0.47,1.1 -1.8,1.05 -2.93,0.7 -1.17,-0.34 -2.04,-1.23 -1.84,-2.38 0.78,-4.43 1.38,-8.97 4.38,-12.62 0.95,-1.15 2.26,-1.74 3.74,-0.86 1.15,0.68 1.8,1.7 1.65,2.9z" />" errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1927,7 +1410,7 @@ errorLine1=" android:pathData="M288.17,570.5c-12.3,0 -24.53,-1.8 -35.3,-6.22 -2.82,-1.16 -8.25,-3.4 -13.7,-7.3 -9.7,-1.62 -19.28,-4.92 -27.95,-9.94 -17.26,-10 -34.4,-25.67 -48.2,-38.26 -3.87,-3.54 -7.52,-7.78 -10.6,-10.45 -11.05,-9.57 -41.26,-24.33 -63.54,-24.33 -0.05,0 -0.1,0 -0.13,0 -21.1,0 -49.48,10.67 -61.18,26.37 -1.56,2.08 -5.9,5.54 -11.53,3.2 -6.9,-2.9 -7.12,-8.33 -5.3,-12.35C18.97,472.94 62.47,456 88.7,456c0.06,0 0.1,0 0.16,0 25.63,0 60.5,15.28 75.8,28.55 3.27,2.82 7.02,6.52 11,10.15 13.1,11.98 29.42,27.04 44.93,36 0.98,0.6 2,1.2 3,1.7 -2.25,-19.02 8.88,-44.9 38.96,-54.04 12.8,-3.88 25.97,2.37 34.4,16.27 9.1,14.93 14,33.56 0.5,47.4 -3.85,3.93 -8.5,7.16 -13.73,9.66 16.06,0.66 33,-2.57 45.4,-6.66 5.04,-1.65 14.67,-5.88 18.98,-7.65 5.9,-2.44 9.57,-3.24 14.94,-4.05 5.32,-0.8 7.05,12.32 5.2,15.9 -0.56,1.08 -8.24,3.48 -11.37,4.67 -1.68,0.64 -16.2,7.03 -21.88,8.9 -13.27,4.37 -30.1,7.67 -46.83,7.67zM246.81,539.1c14.85,1.46 29.3,-1.97 37.26,-10.14 6.54,-6.7 2.2,-16.86 -3.07,-25.54 -1.46,-2.37 -6.66,-9.04 -13,-7.12 -21.37,6.48 -27.76,24.98 -25.65,34.94 0.6,2.9 2.06,5.48 4.46,7.85z" />" errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2048,7 +1531,7 @@ errorLine1=" android:background="?android:attr/colorBackground">" errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2148,7 +1631,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2159,7 +1642,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2313,7 +1796,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2324,7 +1807,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2335,7 +1818,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -2346,7 +1829,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2357,7 +1840,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2368,7 +1851,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2379,7 +1862,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2390,7 +1873,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2401,7 +1884,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2412,7 +1895,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2423,7 +1906,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2434,7 +1917,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2456,7 +1939,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -2467,7 +1950,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2478,7 +1961,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2489,7 +1972,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -2500,7 +1983,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> @@ -2511,7 +1994,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2522,7 +2005,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2533,7 +2016,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -2544,7 +2027,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2555,7 +2038,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -2566,7 +2049,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2577,7 +2060,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2698,7 +2181,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2709,7 +2192,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2720,7 +2203,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2863,7 +2346,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -3050,7 +2533,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3061,7 +2544,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3072,7 +2555,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3160,7 +2643,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3171,7 +2654,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3182,7 +2665,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3193,7 +2676,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3251,6 +2734,7 @@ line="202" column="21"/> + line="56" + message="Access to `private` method `forwardToComposeActivity` of class `MainActivity` requires synthetic accessor" + errorLine1=" forwardToComposeActivity(intent)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3644,7 +3128,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -3655,7 +3139,7 @@ errorLine2=" ~~~~~~~"> @@ -3666,7 +3150,7 @@ errorLine2=" ~~~~~~~"> @@ -3677,7 +3161,7 @@ errorLine2=" ~~~~~~~"> @@ -3688,7 +3172,7 @@ errorLine2=" ~~~~~~~"> @@ -3699,7 +3183,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3710,7 +3194,7 @@ errorLine2=" ~~~~~~~"> @@ -3721,7 +3205,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3732,7 +3216,7 @@ errorLine2=" ~~~~~~~"> @@ -3743,7 +3227,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3754,51 +3238,7 @@ errorLine2=" ~~~~~~~"> - - - - - - - - - - - - - - - - @@ -3864,18 +3304,18 @@ errorLine2=" ~~~~~~~"> + message="Access to `private` method `primaryDrawerItem` of class `MainActivityKt` requires synthetic accessor" + errorLine1=" primaryDrawerItem {" + errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3886,18 +3326,18 @@ errorLine2=" ~~~~~~~"> + message="Access to `private` method `primaryDrawerItem` of class `MainActivityKt` requires synthetic accessor" + errorLine1=" primaryDrawerItem {" + errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3908,7 +3348,7 @@ errorLine2=" ~~~~~~~"> @@ -3919,7 +3359,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3930,7 +3370,7 @@ errorLine2=" ~~~~~~~"> @@ -3941,7 +3381,51 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> + + + + + + + + + + + + + + + + @@ -3952,7 +3436,7 @@ errorLine2=" ~~~~~~~"> @@ -3963,7 +3447,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3974,7 +3458,7 @@ errorLine2=" ~~~~~~~"> @@ -3985,7 +3469,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3996,7 +3480,7 @@ errorLine2=" ~~~~~~~"> @@ -4007,7 +3491,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -4018,7 +3502,7 @@ errorLine2=" ~~~~~~~"> @@ -4029,7 +3513,7 @@ errorLine2=" ~~~~~~~"> @@ -4040,7 +3524,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -4051,7 +3535,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -4062,7 +3546,7 @@ errorLine2=" ~~~~~~~"> @@ -4073,7 +3557,7 @@ errorLine2=" ~~~~~~~"> @@ -4084,7 +3568,7 @@ errorLine2=" ~~~~~~~"> @@ -4095,7 +3579,7 @@ errorLine2=" ~~~~~~~"> @@ -4106,7 +3590,7 @@ errorLine2=" ~~~~~~~"> @@ -4117,7 +3601,7 @@ errorLine2=" ~~~~~~~"> @@ -4414,7 +3898,7 @@ errorLine2=" ~~~~~~~~~"> @@ -4425,7 +3909,7 @@ errorLine2=" ~~~~~~~~"> @@ -4436,7 +3920,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -4447,7 +3931,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -4497,22 +3981,22 @@ @@ -4520,34 +4004,34 @@ + errorLine1=" binding.captionSheet.visible(isDescriptionVisible)" + errorLine2=" ~~~~~~~"> + line="233" + column="21"/> + errorLine1=" if (!isCacheRequest) binding.progressBar.hide()" + errorLine2=" ~~~~~~~"> + line="320" + column="34"/> + errorLine1=" binding.progressBar.hide() // Always hide the progress bar on success" + errorLine2=" ~~~~~~~"> + line="333" + column="13"/> @@ -4568,7 +4052,7 @@ errorLine2=" ~~~~~~~"> @@ -4579,7 +4063,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -4590,7 +4074,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -4601,7 +4085,7 @@ errorLine2=" ~~~~~~~"> @@ -4612,10 +4096,21 @@ errorLine2=" ~~~~~~~"> + + + + - - - - + + + + - - - - @@ -4700,7 +4184,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -4744,285 +4228,10 @@ errorLine2=" ~~~~~~~~~~"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -5701,7 +4910,7 @@ errorLine2=" ~~~~~~~~"> @@ -5712,7 +4921,7 @@ errorLine2=" ~~~~~~~~"> @@ -5723,7 +4932,7 @@ errorLine2=" ~~~~~~~~"> @@ -5734,7 +4943,7 @@ errorLine2=" ~~~~~~~~"> @@ -5745,7 +4954,7 @@ errorLine2=" ~~~~~~~~"> @@ -5910,7 +5119,7 @@ errorLine2=" ~~~~~~~~"> @@ -5921,7 +5130,7 @@ errorLine2=" ~~~~~~~~"> @@ -5932,7 +5141,7 @@ errorLine2=" ~~~~~~~~"> @@ -5943,7 +5152,7 @@ errorLine2=" ~~~~~~~~"> @@ -5954,7 +5163,7 @@ errorLine2=" ~~~~~~~~"> @@ -5965,7 +5174,7 @@ errorLine2=" ~~~~~~~~"> @@ -5976,7 +5185,7 @@ errorLine2=" ~~~~~~~~"> @@ -6321,6 +5530,17 @@ column="6"/> + + + + - - - - - - - - - - - - - - - - - - - - - - - - @@ -6823,7 +5977,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -6834,7 +5988,7 @@ errorLine2=" ~~~~~~~"> @@ -6845,7 +5999,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -6856,7 +6010,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -6867,7 +6021,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -6878,7 +6032,7 @@ errorLine2=" ~~~~~~~"> @@ -6889,7 +6043,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -6900,7 +6054,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -6911,7 +6065,7 @@ errorLine2=" ~~~~~~~"> @@ -6922,7 +6076,7 @@ errorLine2=" ~~~~~~~"> @@ -6933,7 +6087,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -6944,7 +6098,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -7014,50 +6168,6 @@ column="12"/> - - - - - - - - - - - - - - - - @@ -7076,7 +6186,7 @@ errorLine2=" ~~~~~~"> @@ -7087,7 +6197,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -7098,7 +6208,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -7109,7 +6219,7 @@ errorLine2=" ~~~~~~"> @@ -7120,7 +6230,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -7131,7 +6241,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -7142,7 +6252,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -7153,7 +6263,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -7164,7 +6274,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -7175,7 +6285,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -7186,7 +6296,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -7197,7 +6307,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -7208,7 +6318,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -7219,7 +6329,7 @@ errorLine2=" ~~~~~~"> @@ -7230,7 +6340,7 @@ errorLine2=" ~~~~~~"> @@ -7241,7 +6351,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -7252,7 +6362,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -7263,7 +6373,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -7274,7 +6384,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> diff --git a/app/lint.xml b/app/lint.xml index 5fd297c32..522f9aaa7 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -33,9 +33,22 @@ + + + + + + + + + + + + diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/52.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/52.json new file mode 100644 index 000000000..18290c93e --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/52.json @@ -0,0 +1,1009 @@ +{ + "formatVersion": 1, + "database": { + "version": 52, + "identityHash": "233a8680f540e9a89950da21532ce85d", + "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)", + "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" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '233a8680f540e9a89950da21532ce85d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/53.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/53.json new file mode 100644 index 000000000..824768cb5 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/53.json @@ -0,0 +1,1009 @@ +{ + "formatVersion": 1, + "database": { + "version": 53, + "identityHash": "233a8680f540e9a89950da21532ce85d", + "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)", + "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" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '233a8680f540e9a89950da21532ce85d')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/AboutActivity.kt b/app/src/main/java/com/keylesspalace/tusky/AboutActivity.kt index 7c2eedccd..b8b64692e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AboutActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/AboutActivity.kt @@ -1,6 +1,10 @@ package com.keylesspalace.tusky +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context import android.content.Intent +import android.os.Build import android.os.Bundle import android.text.SpannableString import android.text.SpannableStringBuilder @@ -8,13 +12,21 @@ import android.text.method.LinkMovementMethod import android.text.style.URLSpan import android.text.util.Linkify import android.widget.TextView +import android.widget.Toast import androidx.annotation.StringRes +import androidx.lifecycle.lifecycleScope +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.databinding.ActivityAboutBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.util.NoUnderlineURLSpan import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import kotlinx.coroutines.launch +import javax.inject.Inject class AboutActivity : BottomSheetActivity(), Injectable { + @Inject + lateinit var instanceInfoRepository: InstanceInfoRepository override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -32,6 +44,28 @@ class AboutActivity : BottomSheetActivity(), Injectable { binding.versionTextView.text = getString(R.string.about_app_version, getString(R.string.app_name), BuildConfig.VERSION_NAME) + binding.deviceInfo.text = getString( + R.string.about_device_info, + Build.MANUFACTURER, + Build.MODEL, + Build.VERSION.RELEASE, + Build.VERSION.SDK_INT + ) + + lifecycleScope.launch { + accountManager.activeAccount?.let { account -> + val instanceInfo = instanceInfoRepository.getInstanceInfo() + binding.accountInfo.text = getString( + R.string.about_account_info, + account.username, + account.domain, + instanceInfo.version + ) + binding.accountInfoTitle.show() + binding.accountInfo.show() + } + } + if (BuildConfig.CUSTOM_INSTANCE.isBlank()) { binding.aboutPoweredByTusky.hide() } @@ -47,6 +81,16 @@ class AboutActivity : BottomSheetActivity(), Injectable { binding.aboutLicensesButton.setOnClickListener { startActivityWithSlideInAnimation(Intent(this, LicenseActivity::class.java)) } + + 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() + } + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index 62709d7c3..4c8e35f5e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -56,6 +56,8 @@ import java.util.List; import javax.inject.Inject; +import static com.keylesspalace.tusky.settings.PrefKeys.APP_THEME; + public abstract class BaseActivity extends AppCompatActivity implements Injectable { private static final String TAG = "BaseActivity"; @@ -74,7 +76,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab /* 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("appTheme", ThemeUtils.APP_THEME_DEFAULT); + String theme = preferences.getString(APP_THEME, ThemeUtils.APP_THEME_DEFAULT); Log.d("activeTheme", theme); if (theme.equals("black")) { setTheme(R.style.TuskyBlackTheme); @@ -256,9 +258,8 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) { accountManager.setActiveAccount(account.getId()); - Intent intent = new Intent(this, MainActivity.class); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - intent.putExtra(MainActivity.REDIRECT_URL, url); + Intent intent = MainActivity.redirectIntent(this, account.getId(), url); + startActivity(intent); finishWithoutSlideOutAnimation(); } diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt index d62b5c1d1..e09bd4df8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -87,7 +87,7 @@ abstract class BottomSheetActivity : BaseActivity() { viewThread(statuses[0].id, statuses[0].url) return@subscribe } - accounts.firstOrNull { it.url == url }?.let { account -> + accounts.firstOrNull { it.url.equals(url, ignoreCase = true) }?.let { account -> // Some servers return (unrelated) accounts for url searches (#2804) // Verify that the account's url matches the query viewAccount(account.id) diff --git a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt index 190421e69..94c160f74 100644 --- a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt @@ -25,7 +25,9 @@ import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.ImageView +import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible import androidx.lifecycle.LiveData import androidx.lifecycle.lifecycleScope @@ -46,9 +48,11 @@ 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.await import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewmodel.EditProfileViewModel +import com.keylesspalace.tusky.viewmodel.ProfileDataInUi import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt @@ -96,6 +100,14 @@ class EditProfileActivity : BaseActivity(), Injectable { } } + private val currentProfileData + get() = ProfileDataInUi( + displayName = binding.displayNameEditText.text.toString(), + note = binding.noteEditText.text.toString(), + locked = binding.lockedCheckBox.isChecked, + fields = accountFieldEditAdapter.getFieldData() + ) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -200,17 +212,26 @@ class EditProfileActivity : BaseActivity(), Injectable { } } } + + val onBackCallback = object : OnBackPressedCallback(enabled = true) { + override fun handleOnBackPressed() = checkForUnsavedChanges() + } + + onBackPressedDispatcher.addCallback(this, onBackCallback) + } + + fun checkForUnsavedChanges() { + if (viewModel.hasUnsavedChanges(currentProfileData)) { + showUnsavedChangesDialog() + } else { + finish() + } } override fun onStop() { super.onStop() if (!isFinishing) { - viewModel.updateProfile( - binding.displayNameEditText.text.toString(), - binding.noteEditText.text.toString(), - binding.lockedCheckBox.isChecked, - accountFieldEditAdapter.getFieldData() - ) + viewModel.updateProfile(currentProfileData) } } @@ -287,14 +308,7 @@ class EditProfileActivity : BaseActivity(), Injectable { return super.onOptionsItemSelected(item) } - private fun save() { - viewModel.save( - binding.displayNameEditText.text.toString(), - binding.noteEditText.text.toString(), - binding.lockedCheckBox.isChecked, - accountFieldEditAdapter.getFieldData() - ) - } + private fun save() = viewModel.save(currentProfileData) private fun onSaveFailure(msg: String?) { val errorMsg = msg ?: getString(R.string.error_media_upload_sending) @@ -306,4 +320,16 @@ class EditProfileActivity : BaseActivity(), Injectable { Log.w("EditProfileActivity", "failed to pick media", throwable) Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show() } + + private fun showUnsavedChangesDialog() = lifecycleScope.launch { + when (launchSaveDialog()) { + AlertDialog.BUTTON_POSITIVE -> save() + else -> finish() + } + } + + private suspend fun launchSaveDialog() = AlertDialog.Builder(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/ListsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt index ee0187986..8467e8542 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt @@ -23,8 +23,6 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.EditText -import android.widget.FrameLayout import android.widget.ImageButton import android.widget.PopupMenu import android.widget.TextView @@ -38,10 +36,10 @@ import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import at.connyduck.sparkbutton.helpers.Utils import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.databinding.ActivityListsBinding +import com.keylesspalace.tusky.databinding.DialogListBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.MastoList @@ -118,7 +116,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { viewModel.events.collect { event -> when (event) { Event.CREATE_ERROR -> showMessage(R.string.error_create_list) - Event.RENAME_ERROR -> showMessage(R.string.error_rename_list) + Event.UPDATE_ERROR -> showMessage(R.string.error_rename_list) Event.DELETE_ERROR -> showMessage(R.string.error_delete_list) } } @@ -126,16 +124,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { } private fun showlistNameDialog(list: MastoList?) { - val layout = FrameLayout(this) - val editText = EditText(this) - editText.setHint(R.string.hint_list_name) - layout.addView(editText) - val margin = Utils.dpToPx(this, 8) - (editText.layoutParams as ViewGroup.MarginLayoutParams) - .setMargins(margin, margin, margin, 0) - + val binding = DialogListBinding.inflate(layoutInflater) val dialog = AlertDialog.Builder(this) - .setView(layout) + .setView(binding.root) .setPositiveButton( if (list == null) { R.string.action_create_list @@ -143,17 +134,26 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { R.string.action_rename_list } ) { _, _ -> - onPickedDialogName(editText.text, list?.id) + onPickedDialogName(binding.nameText.text.toString(), list?.id, binding.exclusiveCheckbox.isChecked) } .setNegativeButton(android.R.string.cancel, null) .show() - val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE) - editText.doOnTextChanged { s, _, _, _ -> - positiveButton.isEnabled = s?.isNotBlank() == true + binding.nameText.let { editText -> + editText.doOnTextChanged { s, _, _, _ -> + dialog.getButton(Dialog.BUTTON_POSITIVE).isEnabled = s?.isNotBlank() == true + } + editText.setText(list?.title) + editText.text?.let { editText.setSelection(it.length) } + } + + list?.let { + if (it.exclusive == null) { + binding.exclusiveCheckbox.visible(false) + } else { + binding.exclusiveCheckbox.isChecked = it.exclusive + } } - editText.setText(list?.title) - editText.text?.let { editText.setSelection(it.length) } } private fun showListDeleteDialog(list: MastoList) { @@ -174,13 +174,13 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { INITIAL, LOADING -> binding.messageView.hide() ERROR_NETWORK -> { binding.messageView.show() - binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) { + binding.messageView.setup(R.drawable.errorphant_offline, R.string.error_network) { viewModel.retryLoading() } } ERROR_OTHER -> { binding.messageView.show() - binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) { + binding.messageView.setup(R.drawable.errorphant_error, R.string.error_generic) { viewModel.retryLoading() } } @@ -192,6 +192,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { R.string.message_empty, null ) + binding.messageView.showHelp(R.string.help_empty_lists) } else { binding.messageView.hide() } @@ -226,7 +227,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { setOnMenuItemClickListener { item -> when (item.itemId) { R.id.list_edit -> openListSettings(list) - R.id.list_rename -> renameListDialog(list) + R.id.list_update -> renameListDialog(list) R.id.list_delete -> showListDeleteDialog(list) else -> return@setOnMenuItemClickListener false } @@ -287,11 +288,11 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { } } - private fun onPickedDialogName(name: CharSequence, listId: String?) { + private fun onPickedDialogName(name: String, listId: String?, exclusive: Boolean) { if (listId == null) { - viewModel.createNewList(name.toString()) + viewModel.createNewList(name, exclusive) } else { - viewModel.renameList(listId, name.toString()) + viewModel.updateList(listId, name, exclusive) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index c70e83680..e79fab251 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -16,6 +16,8 @@ package com.keylesspalace.tusky import android.Manifest +import android.annotation.SuppressLint +import android.app.NotificationManager import android.content.Context import android.content.DialogInterface import android.content.Intent @@ -33,6 +35,7 @@ import android.view.KeyEvent import android.view.Menu import android.view.MenuInflater import android.view.MenuItem +import android.view.MenuItem.SHOW_AS_ACTION_NEVER import android.view.View import android.widget.ImageView import androidx.activity.OnBackPressedCallback @@ -41,9 +44,12 @@ 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.view.MenuProvider +import androidx.core.view.forEach +import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.viewpager2.widget.MarginPageTransformer @@ -100,7 +106,6 @@ import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.updateShortcut import com.keylesspalace.tusky.util.viewBinding -import com.keylesspalace.tusky.util.visible import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt @@ -158,7 +163,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private lateinit var header: AccountHeaderView - private var notificationTabPosition = 0 private var onTabSelectedListener: OnTabSelectedListener? = null private var unreadAnnouncementsCount = 0 @@ -167,8 +171,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private lateinit var glide: RequestManager - private var accountLocked: Boolean = false - // We need to know if the emoji pack has been changed private var selectedEmojiPack: String? = null @@ -178,6 +180,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje /** Adapter for the different timeline tabs */ private lateinit var tabAdapter: MainPagerAdapter + @SuppressLint("RestrictedApi") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -185,30 +188,39 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje ?: return // will be redirected to LoginActivity by BaseActivity var showNotificationTab = false - if (intent != null) { + + // 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 'account_id' + * - from our code as Long Intent Extra TUSKY_ACCOUNT_ID * - from share shortcuts as String 'android.intent.extra.shortcut.ID' */ - var accountId = intent.getLongExtra(NotificationHelper.ACCOUNT_ID, -1) - if (accountId == -1L) { + var tuskyAccountId = intent.getLongExtra(TUSKY_ACCOUNT_ID, -1) + if (tuskyAccountId == -1L) { val accountIdString = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) if (accountIdString != null) { - accountId = accountIdString.toLong() + tuskyAccountId = accountIdString.toLong() } } - val accountRequested = accountId != -1L - if (accountRequested && accountId != activeAccount.id) { - accountManager.setActiveAccount(accountId) + val accountRequested = tuskyAccountId != -1L + if (accountRequested && tuskyAccountId != activeAccount.id) { + accountManager.setActiveAccount(tuskyAccountId) } val openDrafts = intent.getBooleanExtra(OPEN_DRAFTS, false) - if (canHandleMimeType(intent.type)) { + if (canHandleMimeType(intent.type) || intent.hasExtra(COMPOSE_OPTIONS)) { // Sharing to Tusky from an external app if (accountRequested) { // The correct account is already active - forwardShare(intent) + forwardToComposeActivity(intent) } else { // No account was provided, show the chooser showAccountChooserDialog( @@ -219,10 +231,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje val requestedId = account.id if (requestedId == activeAccount.id) { // The correct account is already active - forwardShare(intent) + forwardToComposeActivity(intent) } else { // A different account was requested, restart the activity - intent.putExtra(NotificationHelper.ACCOUNT_ID, requestedId) + intent.putExtra(TUSKY_ACCOUNT_ID, requestedId) changeAccount(requestedId, intent) } } @@ -232,11 +244,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } else if (openDrafts) { val intent = DraftsActivity.newIntent(this) startActivity(intent) - } else if (accountRequested && savedInstanceState == null) { + } 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(NotificationHelper.TYPE) == Notification.Type.FOLLOW_REQUEST.name) { - val intent = AccountListActivity.newIntent(this, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = true) + if (intent.getSerializableExtra(NOTIFICATION_TYPE) == Notification.Type.FOLLOW_REQUEST) { + val intent = AccountListActivity.newIntent(this, AccountListActivity.Type.FOLLOW_REQUESTS) startActivityWithSlideInAnimation(intent) } else { showNotificationTab = true @@ -245,7 +257,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own setContentView(binding.root) - setSupportActionBar(binding.mainToolbar) glide = Glide.with(this) @@ -254,8 +265,21 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje startActivity(composeIntent) } + // Determine which of the three toolbars should be the supportActionBar (which hosts + // the options menu). val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false) - binding.mainToolbar.visible(!hideTopToolbar) + if (hideTopToolbar) { + when (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top")) { + "top" -> setSupportActionBar(binding.topNav) + "bottom" -> setSupportActionBar(binding.bottomNav) + } + binding.mainToolbar.hide() + // 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.show() + } loadDrawerAvatar(activeAccount.profilePictureUrl, true) @@ -266,7 +290,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje setupDrawer( savedInstanceState, addSearchButton = hideTopToolbar, - addTrendingButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING) + addTrendingTagsButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING_TAGS) ) /* Fetch user info while we're doing other things. This has to be done after setting up the @@ -291,7 +315,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje is MainTabsChangedEvent -> { refreshMainDrawerItems( addSearchButton = hideTopToolbar, - addTrendingButton = !event.newTabs.hasTab(TRENDING) + addTrendingTagsButton = !event.newTabs.hasTab(TRENDING_TAGS) ) setupTabs(false) @@ -353,6 +377,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } } + override fun onPrepareMenu(menu: Menu) { + super.onPrepareMenu(menu) + + // If the main toolbar is hidden then there's no space in the top/bottomNav to show + // the menu items as icons, so forceably disable them + if (!binding.mainToolbar.isVisible) menu.forEach { it.setShowAsAction(SHOW_AS_ACTION_NEVER) } + } + override fun onMenuItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.action_search -> { @@ -425,12 +457,19 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } } - private fun forwardShare(intent: Intent) { - val composeIntent = Intent(this, ComposeActivity::class.java) - composeIntent.action = intent.action - composeIntent.type = intent.type - composeIntent.putExtras(intent) - composeIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + private fun forwardToComposeActivity(intent: Intent) { + val composeOptions = IntentCompat.getParcelableExtra(intent, COMPOSE_OPTIONS, ComposeActivity.ComposeOptions::class.java) + + val composeIntent = if (composeOptions != null) { + ComposeActivity.startIntent(this, composeOptions) + } else { + Intent(this, ComposeActivity::class.java).apply { + action = intent.action + type = intent.type + putExtras(intent) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + } startActivity(composeIntent) finish() } @@ -438,13 +477,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private fun setupDrawer( savedInstanceState: Bundle?, addSearchButton: Boolean, - addTrendingButton: Boolean + addTrendingTagsButton: Boolean ) { val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() } binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener) - binding.topNavAvatar.setOnClickListener(drawerOpenClickListener) - binding.bottomNavAvatar.setOnClickListener(drawerOpenClickListener) + binding.topNav.setNavigationOnClickListener(drawerOpenClickListener) + binding.bottomNav.setNavigationOnClickListener(drawerOpenClickListener) header = AccountHeaderView(this).apply { headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP @@ -499,12 +538,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje }) binding.mainDrawer.apply { - refreshMainDrawerItems(addSearchButton, addTrendingButton) + refreshMainDrawerItems(addSearchButton, addTrendingTagsButton) setSavedInstance(savedInstanceState) } } - private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingButton: Boolean) { + private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingTagsButton: Boolean) { binding.mainDrawer.apply { itemAdapter.clear() tintStatusBar = true @@ -538,7 +577,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje nameRes = R.string.action_view_follow_requests iconicsIcon = GoogleMaterial.Icon.gmd_person_add onClick = { - val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = accountLocked) + val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS) startActivityWithSlideInAnimation(intent) } }, @@ -621,7 +660,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje ) } - if (addTrendingButton) { + if (addTrendingTagsButton) { binding.mainDrawer.addItemsAtPosition( 5, primaryDrawerItem { @@ -756,8 +795,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje activeTabLayout.addOnTabSelectedListener(it) } - val activeTabPosition = if (selectNotificationTab) notificationTabPosition else 0 - supportActionBar?.title = tabs[activeTabPosition].title(this@MainActivity) + supportActionBar?.title = tabs[position].title(this@MainActivity) binding.mainToolbar.setOnClickListener { (tabAdapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect() } @@ -872,8 +910,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje disableAllNotifications(this, accountManager) } - accountLocked = me.locked - updateProfiles() updateShortcut(this, accountManager.activeAccount!!) } @@ -882,112 +918,75 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false) val animateAvatars = preferences.getBoolean("animateGifAvatars", false) - if (hideTopToolbar) { + val activeToolbar = if (hideTopToolbar) { val navOnBottom = preferences.getString("mainNavPosition", "top") == "bottom" - - val avatarView = if (navOnBottom) { - binding.bottomNavAvatar.show() - binding.bottomNavAvatar + if (navOnBottom) { + binding.bottomNav } else { - binding.topNavAvatar.show() - binding.topNavAvatar - } - - if (animateAvatars) { - Glide.with(this) - .load(avatarUrl) - .placeholder(R.drawable.avatar_default) - .into(avatarView) - } else { - Glide.with(this) - .asBitmap() - .load(avatarUrl) - .placeholder(R.drawable.avatar_default) - .into(avatarView) + binding.topNav } } else { - binding.bottomNavAvatar.hide() - binding.topNavAvatar.hide() + binding.mainToolbar + } - val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size) + val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size) - if (animateAvatars) { - glide.asDrawable() - .load(avatarUrl) - .transform( - RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)) - ) - .apply { - if (showPlaceholder) { - placeholder(R.drawable.avatar_default) + if (animateAvatars) { + glide.asDrawable().load(avatarUrl).transform(RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))) + .apply { + if (showPlaceholder) placeholder(R.drawable.avatar_default) + } + .into(object : CustomTarget(navIconSize, navIconSize) { + + override fun onLoadStarted(placeholder: Drawable?) { + placeholder?.let { + activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize) } } - .into(object : CustomTarget(navIconSize, navIconSize) { - override fun onLoadStarted(placeholder: Drawable?) { - if (placeholder != null) { - binding.mainToolbar.navigationIcon = - FixedSizeDrawable(placeholder, navIconSize, navIconSize) - } - } + override fun onResourceReady( + resource: Drawable, + transition: Transition? + ) { + if (resource is Animatable) resource.start() + activeToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize) + } - override fun onResourceReady( - resource: Drawable, - transition: Transition? - ) { - if (resource is Animatable) { - resource.start() - } - binding.mainToolbar.navigationIcon = - FixedSizeDrawable(resource, navIconSize, navIconSize) - } - - override fun onLoadCleared(placeholder: Drawable?) { - if (placeholder != null) { - binding.mainToolbar.navigationIcon = - FixedSizeDrawable(placeholder, navIconSize, navIconSize) - } - } - }) - } else { - glide.asBitmap() - .load(avatarUrl) - .transform( - RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)) - ) - .apply { - if (showPlaceholder) { - placeholder(R.drawable.avatar_default) + override fun onLoadCleared(placeholder: Drawable?) { + placeholder?.let { + activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize) } } - .into(object : CustomTarget(navIconSize, navIconSize) { - - override fun onLoadStarted(placeholder: Drawable?) { - if (placeholder != null) { - binding.mainToolbar.navigationIcon = - FixedSizeDrawable(placeholder, navIconSize, navIconSize) - } + }) + } else { + glide.asBitmap().load(avatarUrl).transform(RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))) + .apply { + if (showPlaceholder) placeholder(R.drawable.avatar_default) + } + .into(object : CustomTarget(navIconSize, navIconSize) { + override fun onLoadStarted(placeholder: Drawable?) { + placeholder?.let { + activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize) } + } - override fun onResourceReady( - resource: Bitmap, - transition: Transition? - ) { - binding.mainToolbar.navigationIcon = FixedSizeDrawable( - BitmapDrawable(resources, resource), - navIconSize, - navIconSize - ) - } + override fun onResourceReady( + resource: Bitmap, + transition: Transition? + ) { + activeToolbar.navigationIcon = FixedSizeDrawable( + BitmapDrawable(resources, resource), + navIconSize, + navIconSize + ) + } - override fun onLoadCleared(placeholder: Drawable?) { - if (placeholder != null) { - binding.mainToolbar.navigationIcon = - FixedSizeDrawable(placeholder, navIconSize, navIconSize) - } + override fun onLoadCleared(placeholder: Drawable?) { + placeholder?.let { + activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize) } - }) - } + } + }) } } @@ -1049,8 +1048,75 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private const val TAG = "MainActivity" // logging tag private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13 private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14 - const val REDIRECT_URL = "redirectUrl" - const val OPEN_DRAFTS = "draft" + private const val REDIRECT_URL = "redirectUrl" + private const val OPEN_DRAFTS = "draft" + private const val TUSKY_ACCOUNT_ID = "tuskyAccountId" + private const val COMPOSE_OPTIONS = "composeOptions" + private const val NOTIFICATION_TYPE = "notificationType" + private const val NOTIFICATION_TAG = "notificationTag" + private const val NOTIFICATION_ID = "notificationId" + + /** + * Switches the active account to the provided accountId and then stays on MainActivity + */ + @JvmStatic + fun accountSwitchIntent(context: Context, tuskyAccountId: Long): Intent { + return Intent(context, MainActivity::class.java).apply { + putExtra(TUSKY_ACCOUNT_ID, tuskyAccountId) + } + } + + /** + * Switches the active account to the accountId and takes the user to the correct place according to the notification they clicked + */ + @JvmStatic + fun openNotificationIntent(context: Context, tuskyAccountId: Long, type: Notification.Type): Intent { + return accountSwitchIntent(context, tuskyAccountId).apply { + putExtra(NOTIFICATION_TYPE, type) + } + } + + /** + * Switches the active account to the accountId and then opens ComposeActivity with the provided options + * @param tuskyAccountId the id of the Tusky account to open the screen with. Set to -1 for current account. + * @param notificationId optional id of the notification that should be cancelled when this intent is opened + * @param notificationTag optional tag of the notification that should be cancelled when this intent is opened + */ + @JvmStatic + fun composeIntent( + context: Context, + options: ComposeActivity.ComposeOptions, + tuskyAccountId: Long = -1, + notificationTag: String? = null, + notificationId: Int = -1 + ): Intent { + return accountSwitchIntent(context, tuskyAccountId).apply { + action = Intent.ACTION_SEND // so it can be opened via shortcuts + putExtra(COMPOSE_OPTIONS, options) + putExtra(NOTIFICATION_TAG, notificationTag) + putExtra(NOTIFICATION_ID, notificationId) + } + } + + /** + * switches the active account to the accountId and then tries to resolve and show the provided url + */ + @JvmStatic + 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 + } + } + + /** + * switches the active account to the provided accountId and then opens drafts + */ + fun draftIntent(context: Context, tuskyAccountId: Long): Intent { + return accountSwitchIntent(context, tuskyAccountId).apply { + putExtra(OPEN_DRAFTS, true) + } + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index c055043d1..39cd0ad09 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -27,6 +27,8 @@ 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.components.filters.EditFilterActivity +import com.keylesspalace.tusky.components.filters.FiltersActivity import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding @@ -132,6 +134,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { { followTagItem?.isVisible = false unfollowTagItem?.isVisible = true + + Snackbar.make(binding.root, getString(R.string.following_hashtag_success_format, tag), Snackbar.LENGTH_SHORT).show() }, { Snackbar.make(binding.root, getString(R.string.error_following_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() @@ -152,6 +156,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { { followTagItem?.isVisible = true unfollowTagItem?.isVisible = false + + Snackbar.make(binding.root, getString(R.string.unfollowing_hashtag_success_format, tag), Snackbar.LENGTH_SHORT).show() }, { Snackbar.make(binding.root, getString(R.string.error_unfollowing_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() @@ -169,6 +175,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { */ private fun updateMuteTagMenuItems() { val tag = hashtag ?: return + val hashedTag = "#$tag" muteTagItem?.isVisible = true muteTagItem?.isEnabled = false @@ -178,9 +185,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { mastodonApi.getFilters().fold( { filters -> mutedFilter = filters.firstOrNull { filter -> - filter.context.contains(Filter.Kind.HOME.kind) && filter.keywords.any { - it.keyword == tag - } + // TODO shouldn't this be an exact match (only one keyword; exactly the hashtag)? + filter.context.contains(Filter.Kind.HOME.kind) && filter.title == hashedTag } updateTagMuteState(mutedFilter != null) }, @@ -189,7 +195,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { mastodonApi.getFiltersV1().fold( { filters -> mutedFilterV1 = filters.firstOrNull { filter -> - tag == filter.phrase && filter.context.contains(FilterV1.HOME) + hashedTag == filter.phrase && filter.context.contains(FilterV1.HOME) } updateTagMuteState(mutedFilterV1 != null) }, @@ -221,6 +227,9 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { val tag = hashtag ?: return true lifecycleScope.launch { + var filterCreateSuccess = false + val hashedTag = "#$tag" + mastodonApi.createFilter( title = "#$tag", context = listOf(FilterV1.HOME), @@ -228,10 +237,13 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { expiresInSeconds = null ).fold( { filter -> - if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tag, wholeWord = true).isSuccess) { - mutedFilter = filter - updateTagMuteState(true) + if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = hashedTag, wholeWord = true).isSuccess) { + // 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])) + filterCreateSuccess = true } else { Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() Log.e(TAG, "Failed to mute #$tag") @@ -240,7 +252,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { { throwable -> if (throwable is HttpException && throwable.code() == 404) { mastodonApi.createFilterV1( - tag, + hashedTag, listOf(FilterV1.HOME), irreversible = false, wholeWord = true, @@ -248,8 +260,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { ).fold( { filter -> mutedFilterV1 = filter - updateTagMuteState(true) eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) + filterCreateSuccess = true }, { throwable -> Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() @@ -262,6 +274,24 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { } } ) + + if (filterCreateSuccess) { + updateTagMuteState(true) + Snackbar.make(binding.root, getString(R.string.muting_hashtag_success_format, tag), Snackbar.LENGTH_LONG).apply { + setAction(R.string.action_view_filter) { + val intent = if (mutedFilter != null) { + Intent(this@StatusListActivity, EditFilterActivity::class.java).apply { + putExtra(EditFilterActivity.FILTER_TO_EDIT, mutedFilter) + } + } else { + Intent(this@StatusListActivity, FiltersActivity::class.java) + } + + startActivityWithSlideInAnimation(intent) + } + show() + } + } } return true @@ -307,6 +337,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { eventHub.dispatch(PreferenceChangedEvent(Filter.Kind.HOME.kind)) mutedFilterV1 = null mutedFilter = null + + Snackbar.make(binding.root, getString(R.string.unmuting_hashtag_success_format, tag), Snackbar.LENGTH_SHORT).show() }, { throwable -> Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index 09d1f1cf4..4760bd5d5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -23,7 +23,7 @@ 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.TrendingFragment +import com.keylesspalace.tusky.components.trending.TrendingTagsFragment import java.util.Objects /** this would be a good case for a sealed class, but that does not work nice with Room */ @@ -33,9 +33,10 @@ const val NOTIFICATIONS = "Notifications" const val LOCAL = "Local" const val FEDERATED = "Federated" const val DIRECT = "Direct" -const val TRENDING = "Trending" +const val TRENDING_TAGS = "TrendingTags" const val HASHTAG = "Hashtag" const val LIST = "List" +const val BOOKMARKS = "Bookmarks" data class TabData( val id: String, @@ -52,9 +53,7 @@ data class TabData( other as TabData if (id != other.id) return false - if (arguments != other.arguments) return false - - return true + return arguments == other.arguments } override fun hashCode() = Objects.hash(id, arguments) @@ -94,11 +93,11 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD icon = R.drawable.ic_reblog_direct_24dp, fragment = { ConversationsFragment.newInstance() } ) - TRENDING -> TabData( - id = TRENDING, + TRENDING_TAGS -> TabData( + id = TRENDING_TAGS, text = R.string.title_public_trending_hashtags, icon = R.drawable.ic_trending_up_24px, - fragment = { TrendingFragment.newInstance() } + fragment = { TrendingTagsFragment.newInstance() } ) HASHTAG -> TabData( id = HASHTAG, @@ -116,6 +115,12 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD arguments = arguments, title = { arguments.getOrNull(1).orEmpty() } ) + BOOKMARKS -> TabData( + id = BOOKMARKS, + text = R.string.title_bookmarks, + icon = R.drawable.ic_bookmark_active_24dp, + fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.BOOKMARKS) } + ) else -> throw IllegalArgumentException("unknown tab type") } } diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index b47194110..29611074e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -21,6 +21,7 @@ import android.os.Bundle import android.util.Log import android.view.Gravity import android.view.View +import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.FrameLayout import android.widget.LinearLayout @@ -273,7 +274,13 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene } private fun showSelectListDialog() { - val adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1) + val adapter = object : ArrayAdapter(this, android.R.layout.simple_list_item_1) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = super.getView(position, convertView, parent) + getItem(position)?.let { item -> (view as TextView).text = item.title } + return view + } + } val statusLayout = LinearLayout(this) statusLayout.gravity = Gravity.CENTER @@ -371,9 +378,13 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene if (!currentTabs.contains(directMessagesTab)) { addableTabs.add(directMessagesTab) } - val trendingTab = createTabDataFromId(TRENDING) - if (!currentTabs.contains(trendingTab)) { - addableTabs.add(trendingTab) + val trendingTagsTab = createTabDataFromId(TRENDING_TAGS) + if (!currentTabs.contains(trendingTagsTab)) { + addableTabs.add(trendingTagsTab) + } + val bookmarksTab = createTabDataFromId(BOOKMARKS) + if (!currentTabs.contains(trendingTagsTab)) { + addableTabs.add(bookmarksTab) } addableTabs.add(createTabDataFromId(HASHTAG)) diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index e7c646991..84fbabbaf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -25,10 +25,13 @@ import androidx.work.WorkManager import autodispose2.AutoDisposePlugins import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.di.AppInjector +import com.keylesspalace.tusky.settings.NEW_INSTALL_SCHEMA_VERSION import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME import com.keylesspalace.tusky.settings.SCHEMA_VERSION import com.keylesspalace.tusky.util.APP_THEME_DEFAULT import com.keylesspalace.tusky.util.LocaleManager +import com.keylesspalace.tusky.util.THEME_NIGHT import com.keylesspalace.tusky.util.setAppNightMode import com.keylesspalace.tusky.worker.PruneCacheWorker import com.keylesspalace.tusky.worker.WorkerFactory @@ -76,7 +79,7 @@ class TuskyApplication : Application(), HasAndroidInjector { AppInjector.init(this) // Migrate shared preference keys and defaults from version to version. - val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, 0) + val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, NEW_INSTALL_SCHEMA_VERSION) if (oldVersion != SCHEMA_VERSION) { upgradeSharedPreferences(oldVersion, SCHEMA_VERSION) } @@ -87,7 +90,7 @@ class TuskyApplication : Application(), HasAndroidInjector { EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false) // init night mode - val theme = sharedPreferences.getString("appTheme", APP_THEME_DEFAULT) + val theme = sharedPreferences.getString(APP_THEME, APP_THEME_DEFAULT) setAppNightMode(theme) localeManager.setLocale() @@ -130,6 +133,20 @@ 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)) { + editor.putString(APP_THEME, THEME_NIGHT) + } + } 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 1e017827c..88b330c84 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -59,6 +59,8 @@ import com.keylesspalace.tusky.pager.SingleImagePagerAdapter import com.keylesspalace.tusky.util.getTemporaryMediaFilename import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData +import dagger.android.DispatchingAndroidInjector +import dagger.android.HasAndroidInjector import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers @@ -67,10 +69,13 @@ import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.IOException import java.util.Locale +import javax.inject.Inject typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit -class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener { +class ViewMediaActivity : BaseActivity(), HasAndroidInjector, ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener { + @Inject + lateinit var androidInjector: DispatchingAndroidInjector private val binding by viewBinding(ActivityViewMediaBinding::inflate) @@ -337,6 +342,8 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener shareFile(file, mimeType) } + 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/AccountFieldEditAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt index 96774ac0b..a57c0aba9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt @@ -82,11 +82,11 @@ class AccountFieldEditAdapter : RecyclerView.Adapter - fieldData[holder.bindingAdapterPosition].first = newText.toString() + fieldData.getOrNull(holder.bindingAdapterPosition)?.first = newText.toString() } holder.binding.accountFieldValueText.doAfterTextChanged { newText -> - fieldData[holder.bindingAdapterPosition].second = newText.toString() + fieldData.getOrNull(holder.bindingAdapterPosition)?.second = newText.toString() } // Ensure the textview contents are selectable 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 de4d23802..5435dc8f3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -52,6 +52,7 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; import com.keylesspalace.tusky.util.AttachmentHelper; import com.keylesspalace.tusky.util.CardViewMode; +import com.keylesspalace.tusky.util.CompositeWithOpaqueBackground; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.LinkHelper; @@ -67,6 +68,7 @@ import com.keylesspalace.tusky.viewdata.PollViewDataKt; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.text.NumberFormat; +import java.util.Collections; import java.util.Date; import java.util.List; @@ -114,10 +116,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private final TextView cardDescription; private final TextView cardUrl; private final PollAdapter pollAdapter; - protected LinearLayout filteredPlaceholder; - protected TextView filteredPlaceholderLabel; - protected Button filteredPlaceholderShowButton; - protected ConstraintLayout statusContainer; + protected final LinearLayout filteredPlaceholder; + protected final TextView filteredPlaceholderLabel; + protected final Button filteredPlaceholderShowButton; + protected final ConstraintLayout statusContainer; private final NumberFormat numberFormat = NumberFormat.getNumberInstance(); private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); @@ -328,14 +330,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { avatarInset.setVisibility(View.VISIBLE); avatarInset.setBackground(null); ImageLoadingHelper.loadAvatar(rebloggedUrl, avatarInset, avatarRadius24dp, - statusDisplayOptions.animateAvatars()); + statusDisplayOptions.animateAvatars(), null); avatarRadius = avatarRadius36dp; } ImageLoadingHelper.loadAvatar(url, avatar, avatarRadius, - statusDisplayOptions.animateAvatars()); - + statusDisplayOptions.animateAvatars(), + Collections.singletonList(new CompositeWithOpaqueBackground(avatar))); } protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { @@ -838,9 +840,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } filteredPlaceholderLabel.setText(itemView.getContext().getString(R.string.status_filter_placeholder_label_format, matchedFilter.getTitle())); - filteredPlaceholderShowButton.setOnClickListener(view -> { - listener.clearWarningAction(getBindingAdapterPosition()); - }); + filteredPlaceholderShowButton.setOnClickListener(view -> listener.clearWarningAction(getBindingAdapterPosition())); } protected static boolean hasPreviewableAttachment(List attachments) { 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 ea9075210..f5e962d8e 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 @@ -772,13 +772,16 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide loadedAccount?.let { loadedAccount -> val muteDomain = menu.findItem(R.id.action_mute_domain) domain = getDomain(loadedAccount.url) - if (domain.isEmpty()) { + when { // If we can't get the domain, there's no way we can mute it anyway... - menu.removeItem(R.id.action_mute_domain) - } else { - if (blockingDomain) { + // If the account is from our own domain, muting it is no-op + domain.isEmpty() || viewModel.isFromOwnDomain -> { + menu.removeItem(R.id.action_mute_domain) + } + blockingDomain -> { muteDomain.title = getString(R.string.action_unmute_domain, domain) - } else { + } + else -> { muteDomain.title = getString(R.string.action_mute_domain, domain) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt index 5b32e3404..b12e3923d 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,6 +19,7 @@ import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Success +import com.keylesspalace.tusky.util.getDomain import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -27,7 +28,7 @@ import javax.inject.Inject class AccountViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub, - private val accountManager: AccountManager + accountManager: AccountManager ) : ViewModel() { val accountData = MutableLiveData>() @@ -41,8 +42,13 @@ class AccountViewModel @Inject constructor( lateinit var accountId: String var isSelf = false + /** True if the viewed account has the same domain as the active account */ + var isFromOwnDomain = false + private var noteUpdateJob: Job? = null + private val activeAccount = accountManager.activeAccount!! + init { viewModelScope.launch { eventHub.events.collect { event -> @@ -65,6 +71,8 @@ class AccountViewModel @Inject constructor( accountData.postValue(Success(account)) isDataLoading = false isRefreshing.postValue(false) + + isFromOwnDomain = getDomain(account.url) == activeAccount.domain }, { t -> Log.w(TAG, "failed obtaining account", t) @@ -298,7 +306,7 @@ class AccountViewModel @Inject constructor( fun setAccountInfo(accountId: String) { this.accountId = accountId - this.isSelf = accountManager.activeAccount?.accountId == accountId + this.isSelf = activeAccount.accountId == accountId reload(false) } 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 b39a8b5b4..5a81c418e 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 @@ -82,13 +82,10 @@ class AccountMediaFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - val alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia - val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true) adapter = AccountMediaGridAdapter( - alwaysShowSensitiveMedia = alwaysShowSensitiveMedia, useBlurhash = useBlurhash, context = view.context, onAttachmentClickListener = ::onAttachmentClick 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 48b3a21da..aecbeb0bb 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 @@ -24,7 +24,6 @@ import com.keylesspalace.tusky.viewdata.AttachmentViewData import java.util.Random class AccountMediaGridAdapter( - private val alwaysShowSensitiveMedia: Boolean, private val useBlurhash: Boolean, context: Context, private val onAttachmentClickListener: (AttachmentViewData, View) -> Unit @@ -80,7 +79,7 @@ class AccountMediaGridAdapter( .into(imageView) imageView.contentDescription = item.attachment.getFormattedDescription(context) - } else if (item.sensitive && !item.isRevealed && !alwaysShowSensitiveMedia) { + } else if (item.sensitive && !item.isRevealed) { overlay.show() overlay.setImageDrawable(mediaHiddenDrawable) 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 315b0380c..52535a6a5 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,6 +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.network.MastodonApi import com.keylesspalace.tusky.viewdata.AttachmentViewData import retrofit2.HttpException @@ -27,9 +28,9 @@ import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) class AccountMediaRemoteMediator( private val api: MastodonApi, + private val activeAccount: AccountEntity, private val viewModel: AccountMediaViewModel ) : RemoteMediator() { - override suspend fun load( loadType: LoadType, state: PagingState @@ -58,7 +59,7 @@ class AccountMediaRemoteMediator( } val attachments = statuses.flatMap { status -> - AttachmentViewData.list(status) + AttachmentViewData.list(status, activeAccount.alwaysShowSensitiveMedia ?: false) } 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 ddbcb71d0..ee5ffd011 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 @@ -21,11 +21,13 @@ import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn +import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.viewdata.AttachmentViewData import javax.inject.Inject class AccountMediaViewModel @Inject constructor( + private val accountManager: AccountManager, api: MastodonApi ) : ViewModel() { @@ -35,6 +37,8 @@ class AccountMediaViewModel @Inject constructor( var currentSource: AccountMediaPagingSource? = null + val activeAccount = accountManager.activeAccount!! + @OptIn(ExperimentalPagingApi::class) val media = Pager( config = PagingConfig( @@ -48,7 +52,7 @@ class AccountMediaViewModel @Inject constructor( currentSource = source } }, - remoteMediator = AccountMediaRemoteMediator(api, this) + remoteMediator = AccountMediaRemoteMediator(api, activeAccount, this) ).flow .cachedIn(viewModelScope) 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 f5981c0bc..2419dc915 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 @@ -48,7 +48,6 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector { val type = intent.getSerializableExtra(EXTRA_TYPE) as Type val id: String? = intent.getStringExtra(EXTRA_ID) - val accountLocked: Boolean = intent.getBooleanExtra(EXTRA_ACCOUNT_LOCKED, false) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.apply { @@ -66,7 +65,7 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector { } supportFragmentManager.commit { - replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked)) + replace(R.id.fragment_container, AccountListFragment.newInstance(type, id)) } } @@ -75,13 +74,11 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector { companion object { private const val EXTRA_TYPE = "type" private const val EXTRA_ID = "id" - private const val EXTRA_ACCOUNT_LOCKED = "acc_locked" - fun newIntent(context: Context, type: Type, id: String? = null, accountLocked: Boolean = false): Intent { + fun newIntent(context: Context, type: Type, id: String? = null): Intent { return Intent(context, AccountListActivity::class.java).apply { putExtra(EXTRA_TYPE, type) putExtra(EXTRA_ID, id) - putExtra(EXTRA_ACCOUNT_LOCKED, accountLocked) } } } 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 68eba3c76..e08429aad 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 @@ -61,7 +61,6 @@ import com.keylesspalace.tusky.view.EndlessOnScrollListener import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import kotlinx.coroutines.launch import retrofit2.Response -import java.io.IOException import javax.inject.Inject class AccountListFragment : @@ -107,13 +106,15 @@ class AccountListFragment : val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) val showBotOverlay = pm.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true) + val activeAccount = accountManager.activeAccount!! + adapter = when (type) { Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay) Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay) Type.FOLLOW_REQUESTS -> { val headerAdapter = FollowRequestsHeaderAdapter( - instanceName = accountManager.activeAccount!!.domain, - accountLocked = arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true + instanceName = activeAccount.domain, + accountLocked = activeAccount.locked ) val followRequestsAdapter = FollowRequestsAdapter(this, this, animateAvatar, animateEmojis, showBotOverlay) binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter) @@ -330,7 +331,7 @@ class AccountListFragment : val linkHeader = response.headers()["Link"] onFetchAccountsSuccess(accountList, linkHeader) - } catch (exception: IOException) { + } catch (exception: Exception) { onFetchAccountsFailure(exception) } } @@ -404,14 +405,12 @@ class AccountListFragment : private const val TAG = "AccountList" // logging tag private const val ARG_TYPE = "type" private const val ARG_ID = "id" - private const val ARG_ACCOUNT_LOCKED = "acc_locked" - fun newInstance(type: Type, id: String? = null, accountLocked: Boolean = false): AccountListFragment { + fun newInstance(type: Type, id: String? = null): AccountListFragment { return AccountListFragment().apply { arguments = Bundle(3).apply { putSerializable(ARG_TYPE, type) putString(ARG_ID, id) - putBoolean(ARG_ACCOUNT_LOCKED, accountLocked) } } } 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 a62fb12b9..cc503a7c8 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 @@ -129,7 +129,7 @@ class AnnouncementsActivity : is Error -> { binding.progressBar.hide() binding.swipeRefreshLayout.isRefreshing = false - binding.errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) { + binding.errorMessageView.setup(R.drawable.errorphant_error, R.string.error_generic) { refreshAnnouncements() } binding.errorMessageView.show() 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 d5862dad2..14f9b947e 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,7 +16,6 @@ package com.keylesspalace.tusky.components.compose import android.Manifest -import android.app.NotificationManager import android.app.ProgressDialog import android.content.ClipData import android.content.Context @@ -95,6 +94,7 @@ import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME import com.keylesspalace.tusky.util.APP_THEME_DEFAULT import com.keylesspalace.tusky.util.MentionSpan import com.keylesspalace.tusky.util.PickMediaFiles @@ -207,24 +207,9 @@ class ComposeActivity : public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val notificationId = intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1) - if (notificationId != -1) { - // ComposeActivity was opened from a notification, delete the notification - val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - notificationManager.cancel(notificationId) - } + activeAccount = accountManager.activeAccount ?: return - // If started from an intent then compose as the account ID from the intent. - // Otherwise use the active account. If null then the user is not logged in, - // and return from the activity. - val intentAccountId = intent.getLongExtra(ACCOUNT_ID_EXTRA, -1) - activeAccount = if (intentAccountId != -1L) { - accountManager.getAccountById(intentAccountId) - } else { - accountManager.activeAccount - } ?: return - - val theme = preferences.getString("appTheme", APP_THEME_DEFAULT) + val theme = preferences.getString(APP_THEME, APP_THEME_DEFAULT) if (theme == "black") { setTheme(R.style.TuskyDialogActivityBlackTheme) } @@ -280,7 +265,7 @@ class ComposeActivity : binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt) } - setupLanguageSpinner(getInitialLanguages(composeOptions?.language, accountManager.activeAccount)) + setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount)) setupComposeField(preferences, viewModel.startingText) setupContentWarningField(composeOptions?.contentWarning) setupPollView() @@ -485,7 +470,12 @@ class ComposeActivity : if (throwable is UploadServerError) { displayTransientMessage(throwable.errorMessage) } else { - displayTransientMessage(R.string.error_media_upload_sending) + displayTransientMessage( + getString( + R.string.error_media_upload_sending_fmt, + throwable.message + ) + ) } } } @@ -943,7 +933,10 @@ class ComposeActivity : val split = contentInfo.partition { item: ClipData.Item -> item.uri != null } split.first?.let { content -> for (i in 0 until content.clip.itemCount) { - pickMedia(content.clip.getItemAt(i).uri) + pickMedia( + content.clip.getItemAt(i).uri, + contentInfo.clip.description.label as String? + ) } } return split.second @@ -1064,9 +1057,9 @@ class ComposeActivity : viewModel.removeMediaFromQueue(item) } - private fun pickMedia(uri: Uri) { + private fun pickMedia(uri: Uri, description: String? = null) { lifecycleScope.launch { - viewModel.pickMedia(uri).onFailure { throwable -> + viewModel.pickMedia(uri, description).onFailure { throwable -> val errorString = when (throwable) { is FileSizeException -> { val decimalFormat = DecimalFormat("0.##") @@ -1347,8 +1340,6 @@ class ComposeActivity : private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1 internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS" - private const val NOTIFICATION_ID_EXTRA = "NOTIFICATION_ID" - private const val ACCOUNT_ID_EXTRA = "ACCOUNT_ID" private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI" private const val VISIBILITY_KEY = "VISIBILITY" private const val SCHEDULED_TIME_KEY = "SCHEDULE" @@ -1356,26 +1347,15 @@ class ComposeActivity : /** * @param options ComposeOptions to configure the ComposeActivity - * @param notificationId the id of the notification that starts the Activity - * @param accountId the id of the account to compose with, null for the current account * @return an Intent to start the ComposeActivity */ @JvmStatic - @JvmOverloads fun startIntent( context: Context, - options: ComposeOptions, - notificationId: Int? = null, - accountId: Long? = null + options: ComposeOptions ): Intent { return Intent(context, ComposeActivity::class.java).apply { putExtra(COMPOSE_OPTIONS_EXTRA, options) - if (notificationId != null) { - putExtra(NOTIFICATION_ID_EXTRA, notificationId) - } - if (accountId != null) { - putExtra(ACCOUNT_ID_EXTRA, accountId) - } } } 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 90389d28c..0919d29d0 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 @@ -39,7 +39,6 @@ import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.StatusToSend import com.keylesspalace.tusky.util.randomAlphanumericString import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -53,7 +52,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject -@OptIn(FlowPreview::class) class ComposeViewModel @Inject constructor( private val api: MastodonApi, private val accountManager: AccountManager, @@ -95,7 +93,7 @@ class ComposeViewModel @Inject constructor( val media: MutableStateFlow> = MutableStateFlow(emptyList()) val uploadError = MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) - lateinit var composeKind: ComposeKind + private lateinit var composeKind: ComposeKind // Used in ComposeActivity to pass state to result function when cropImage contract inflight var cropImageItemOld: QueuedMedia? = null diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt index ded1b7cfd..4d7ecdca8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt @@ -75,10 +75,6 @@ class AddPollOptionsAdapter( } private fun validateInput(): Boolean { - if (options.contains("") || options.distinct().size != options.size) { - return false - } - - return true + return !(options.contains("") || options.distinct().size != options.size) } } 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 a1dc032cd..c066b3d9f 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 @@ -51,7 +51,7 @@ class CaptionDialog : DialogFragment() { input = binding.imageDescriptionText val imageView = binding.imageDescriptionView - imageView.maximumScale = 6f + imageView.maxZoom = 6f input.hint = resources.getQuantityString( R.plurals.hint_describe_for_visually_impaired, 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 f2e00d341..2f6fad162 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 @@ -57,7 +57,9 @@ class ComposeScheduleView ).apply { timeZone = TimeZone.getTimeZone("UTC") } - private var scheduleDateTime: Calendar? = null + + /** The date/time the user has chosen to schedule the status, in UTC */ + private var scheduleDateTimeUtc: Calendar? = null init { binding.scheduledDateTime.setOnClickListener { openPickDateDialog() } @@ -71,13 +73,13 @@ class ComposeScheduleView } private fun updateScheduleUi() { - if (scheduleDateTime == null) { + if (scheduleDateTimeUtc == null) { binding.scheduledDateTime.text = "" binding.invalidScheduleWarning.visibility = GONE return } - val scheduled = scheduleDateTime!!.time + val scheduled = scheduleDateTimeUtc!!.time binding.scheduledDateTime.text = String.format( "%s %s", dateFormat.format(scheduled), @@ -98,21 +100,37 @@ class ComposeScheduleView } fun resetSchedule() { - scheduleDateTime = null + scheduleDateTimeUtc = null updateScheduleUi() } fun openPickDateDialog() { - val yesterday = Calendar.getInstance().timeInMillis - 24 * 60 * 60 * 1000 + // The earliest point in time the calendar should display. Start with current date/time + val earliest = calendar().apply { + // Add the minimum scheduling interval. This may roll the calendar over to the + // next day (e.g. if the current time is 23:57). + add(Calendar.SECOND, MINIMUM_SCHEDULED_SECONDS) + // Clear out the time components, so it's midnight + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } val calendarConstraints = CalendarConstraints.Builder() - .setValidator( - DateValidatorPointForward.from(yesterday) - ) + .setValidator(DateValidatorPointForward.from(earliest.timeInMillis)) .build() initializeSuggestedTime() + + // Work around a misfeature in MaterialDatePicker. The `selection` is treated as + // millis-from-epoch, in UTC, which is good. However, it is also *displayed* in UTC + // instead of converting to the user's local timezone. + // + // So we have to add the TZ offset before setting it in the picker + val tzOffset = TimeZone.getDefault().getOffset(scheduleDateTimeUtc!!.timeInMillis) + val picker = MaterialDatePicker.Builder .datePicker() - .setSelection(scheduleDateTime!!.timeInMillis) + .setSelection(scheduleDateTimeUtc!!.timeInMillis + tzOffset) .setCalendarConstraints(calendarConstraints) .build() picker.addOnPositiveButtonClickListener { selection: Long -> onDateSet(selection) } @@ -129,11 +147,12 @@ class ComposeScheduleView private fun openPickTimeDialog() { val pickerBuilder = MaterialTimePicker.Builder() - scheduleDateTime?.let { + scheduleDateTimeUtc?.let { pickerBuilder.setHour(it[Calendar.HOUR_OF_DAY]) .setMinute(it[Calendar.MINUTE]) } + pickerBuilder.setTitleText(dateFormat.format(scheduleDateTimeUtc!!.timeInMillis)) pickerBuilder.setTimeFormat(getTimeFormat(context)) val picker = pickerBuilder.build() @@ -154,7 +173,7 @@ class ComposeScheduleView fun setDateTime(scheduledAt: String?) { val date = getDateTime(scheduledAt) ?: return initializeSuggestedTime() - scheduleDateTime!!.time = date + scheduleDateTimeUtc!!.time = date updateScheduleUi() } @@ -180,24 +199,24 @@ class ComposeScheduleView // see https://github.com/material-components/material-components-android/issues/882 newDate.timeZone = TimeZone.getTimeZone("UTC") newDate.timeInMillis = selection - scheduleDateTime!![newDate[Calendar.YEAR], newDate[Calendar.MONTH]] = newDate[Calendar.DATE] + scheduleDateTimeUtc!![newDate[Calendar.YEAR], newDate[Calendar.MONTH]] = newDate[Calendar.DATE] openPickTimeDialog() } private fun onTimeSet(hourOfDay: Int, minute: Int) { initializeSuggestedTime() - scheduleDateTime?.set(Calendar.HOUR_OF_DAY, hourOfDay) - scheduleDateTime?.set(Calendar.MINUTE, minute) + scheduleDateTimeUtc?.set(Calendar.HOUR_OF_DAY, hourOfDay) + scheduleDateTimeUtc?.set(Calendar.MINUTE, minute) updateScheduleUi() listener?.onTimeSet(time) } val time: String? - get() = scheduleDateTime?.time?.let { iso8601.format(it) } + get() = scheduleDateTimeUtc?.time?.let { iso8601.format(it) } private fun initializeSuggestedTime() { - if (scheduleDateTime == null) { - scheduleDateTime = calendar().apply { + if (scheduleDateTimeUtc == null) { + scheduleDateTimeUtc = calendar().apply { add(Calendar.MINUTE, 15) } } 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 722a9f3c5..5dc0bf982 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 @@ -144,7 +144,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { ImageView avatarView = avatars[i]; if (i < accounts.size()) { ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView, - avatarRadius48dp, statusDisplayOptions.animateAvatars()); + avatarRadius48dp, statusDisplayOptions.animateAvatars(), null); avatarView.setVisibility(View.VISIBLE); } else { avatarView.setVisibility(View.GONE); 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 48d1e87b8..97a4d7583 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 @@ -134,6 +134,7 @@ class ConversationsFragment : if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) { binding.statusView.show() binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) + binding.statusView.showHelp(R.string.help_empty_conversations) } } is LoadState.Error -> { 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 2ef7902cc..f8a291c4b 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,6 +1,7 @@ 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.widget.AdapterView @@ -81,7 +82,11 @@ class EditFilterActivity : BaseActivity() { binding.actionChip.setOnClickListener { showAddKeywordDialog() } binding.filterSaveButton.setOnClickListener { saveChanges() } - binding.filterDeleteButton.setOnClickListener { deleteFilter() } + binding.filterDeleteButton.setOnClickListener { + lifecycleScope.launch { + if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) deleteFilter() + } + } binding.filterDeleteButton.visible(originalFilter != null) for (switch in contextSwitches.keys) { 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 new file mode 100644 index 000000000..2a6c69d21 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FilterExtensions.kt @@ -0,0 +1,29 @@ +/* + * 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.filters + +import android.app.Activity +import androidx.appcompat.app.AlertDialog +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) 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 9230fdae2..d66fb7ad2 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 @@ -1,5 +1,6 @@ package com.keylesspalace.tusky.components.filters +import android.content.DialogInterface.BUTTON_POSITIVE import android.content.Intent import android.os.Bundle import androidx.activity.viewModels @@ -60,18 +61,19 @@ class FiltersActivity : BaseActivity(), FiltersListener { when (state.loadingState) { FiltersViewModel.LoadingState.INITIAL, FiltersViewModel.LoadingState.LOADING -> binding.messageView.hide() FiltersViewModel.LoadingState.ERROR_NETWORK -> { - binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) { + binding.messageView.setup(R.drawable.errorphant_offline, R.string.error_network) { loadFilters() } binding.messageView.show() } FiltersViewModel.LoadingState.ERROR_OTHER -> { - binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) { + binding.messageView.setup(R.drawable.errorphant_error, R.string.error_generic) { loadFilters() } binding.messageView.show() } FiltersViewModel.LoadingState.LOADED -> { + binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters) if (state.filters.isEmpty()) { binding.messageView.setup( R.drawable.elephant_friend_empty, @@ -81,7 +83,6 @@ class FiltersActivity : BaseActivity(), FiltersListener { binding.messageView.show() } else { binding.messageView.hide() - binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters) } } } @@ -104,7 +105,11 @@ class FiltersActivity : BaseActivity(), FiltersListener { } override fun deleteFilter(filter: Filter) { - viewModel.deleteFilter(filter, binding.root) + lifecycleScope.launch { + if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) { + viewModel.deleteFilter(filter, binding.root) + } + } } override fun updateFilter(updatedFilter: Filter) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt index bb97621c1..582df02e8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt @@ -28,5 +28,6 @@ data class InstanceInfo( val maxMediaAttachments: Int, val maxFields: Int, val maxFieldNameLength: Int?, - val maxFieldValueLength: Int? + val maxFieldValueLength: Int?, + val version: String? ) 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 bfc5a9b8c..1045fe480 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 @@ -99,7 +99,8 @@ class InstanceInfoRepository @Inject constructor( maxMediaAttachments = instanceInfo?.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS, maxFields = instanceInfo?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS, maxFieldNameLength = instanceInfo?.maxFieldNameLength, - maxFieldValueLength = instanceInfo?.maxFieldValueLength + maxFieldValueLength = instanceInfo?.maxFieldValueLength, + version = instanceInfo?.version ) } } 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 be7e323ad..ed5744165 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 @@ -99,7 +99,7 @@ sealed class LoginResult : Parcelable { data class Err(val errorMessage: String) : LoginResult() @Parcelize - object Cancel : LoginResult() + data object Cancel : LoginResult() } /** Activity to do Oauth process using WebView. */ diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java index 6a42e8977..f15d44d2a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java @@ -85,13 +85,6 @@ public class NotificationHelper { /** Dynamic notification IDs start here */ private static int notificationId = NOTIFICATION_ID_PRUNE_CACHE + 1; - /** - * constants used in Intents - */ - public static final String ACCOUNT_ID = "account_id"; - - public static final String TYPE = APPLICATION_ID + ".notification.type"; - private static final String TAG = "NotificationHelper"; public static final String REPLY_ACTION = "REPLY_ACTION"; @@ -245,7 +238,7 @@ public class NotificationHelper { Bundle extras = new Bundle(); // Add the sending account's name, so it can be used when summarising this notification extras.putString(EXTRA_ACCOUNT_NAME, body.getAccount().getName()); - extras.putString(EXTRA_NOTIFICATION_TYPE, body.getType().toString()); + extras.putSerializable(EXTRA_NOTIFICATION_TYPE, body.getType()); builder.addExtras(extras); // Only alert for the first notification of a batch to avoid multiple alerts at once @@ -285,7 +278,7 @@ public class NotificationHelper { int accountId = (int) account.getId(); // Initialise the map with all channel IDs. - for (Notification.Type ty : Notification.Type.values()) { + for (Notification.Type ty : Notification.Type.getEntries()) { channelGroups.put(getChannelId(account, ty), new ArrayList<>()); } @@ -325,11 +318,10 @@ public class NotificationHelper { // Create a notification that summarises the other notifications in this group // All notifications in this group have the same type, so get it from the first. - String notificationType = members.get(0).getNotification().extras.getString(EXTRA_NOTIFICATION_TYPE); + Notification.Type notificationType = (Notification.Type)members.get(0).getNotification().extras.getSerializable(EXTRA_NOTIFICATION_TYPE); + + Intent summaryResultIntent = MainActivity.openNotificationIntent(context, accountId, notificationType); - Intent summaryResultIntent = new Intent(context, MainActivity.class); - summaryResultIntent.putExtra(ACCOUNT_ID, (long) accountId); - summaryResultIntent.putExtra(TYPE, notificationType); TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context); summaryStackBuilder.addParentStack(MainActivity.class); summaryStackBuilder.addNextIntent(summaryResultIntent); @@ -373,10 +365,8 @@ public class NotificationHelper { private static NotificationCompat.Builder newAndroidNotification(Context context, Notification body, AccountEntity account) { - // we have to switch account here - Intent eventResultIntent = new Intent(context, MainActivity.class); - eventResultIntent.putExtra(ACCOUNT_ID, account.getId()); - eventResultIntent.putExtra(TYPE, body.getType().name()); + Intent eventResultIntent = MainActivity.openNotificationIntent(context, account.getId(), body.getType()); + TaskStackBuilder eventStackBuilder = TaskStackBuilder.create(context); eventStackBuilder.addParentStack(MainActivity.class); eventStackBuilder.addNextIntent(eventResultIntent); @@ -464,12 +454,7 @@ public class NotificationHelper { composeOptions.setLanguage(actionableStatus.getLanguage()); composeOptions.setKind(ComposeActivity.ComposeKind.NEW); - Intent composeIntent = ComposeActivity.startIntent( - context, - composeOptions, - notificationId, - account.getId() - ); + Intent composeIntent = MainActivity.composeIntent(context, composeOptions, account.getId(), body.getId(), (int)account.getId()); composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 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 index 78f7a1565..13d11eeb6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -28,7 +28,6 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AlertDialog -import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.MenuProvider import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment @@ -46,7 +45,6 @@ import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import at.connyduck.sparkbutton.helpers.Utils -import com.google.android.material.appbar.AppBarLayout.ScrollingViewBehavior import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R @@ -123,21 +121,6 @@ class NotificationsFragment : return inflater.inflate(R.layout.fragment_timeline_notifications, container, false) } - private fun updateFilterVisibility(showFilter: Boolean) { - val params = binding.swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams - if (showFilter) { - binding.appBarOptions.setExpanded(true, false) - binding.appBarOptions.visibility = View.VISIBLE - // Set content behaviour to hide filter on scroll - params.behavior = ScrollingViewBehavior() - } else { - binding.appBarOptions.setExpanded(false, false) - binding.appBarOptions.visibility = View.GONE - // Clear behaviour to hide app bar - params.behavior = null - } - } - private fun confirmClearNotifications() { AlertDialog.Builder(requireContext()) .setMessage(R.string.notification_clear_text) @@ -215,8 +198,6 @@ class NotificationsFragment : footer = NotificationsLoadStateAdapter { adapter.retry() } ) - binding.buttonClear.setOnClickListener { confirmClearNotifications() } - binding.buttonFilter.setOnClickListener { showFilterDialog() } (binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations = false @@ -293,7 +274,7 @@ class NotificationsFragment : val position = adapter.snapshot().indexOfFirst { it?.statusViewData?.status?.id == (action as StatusAction).statusViewData.id } - if (position != RecyclerView.NO_POSITION) { + if (position != NO_POSITION) { adapter.notifyItemChanged(position) } } @@ -369,10 +350,10 @@ class NotificationsFragment : } } - // Update filter option visibility from uiState - launch { - viewModel.uiState.collectLatest { updateFilterVisibility(it.showFilterOptions) } - } + // Collect the uiState. Nothing is done with it, but if you don't collect it then + // accessing viewModel.uiState.value (e.g., when the filter dialog is created) + // returns an empty object. + launch { viewModel.uiState.collect() } // Update status display from statusDisplayOptions. If the new options request // relative time display collect the flow to periodically update the timestamp in the list gui elements. @@ -418,13 +399,13 @@ class NotificationsFragment : when ((loadState.refresh as LoadState.Error).error) { is IOException -> { binding.statusView.setup( - R.drawable.elephant_offline, + R.drawable.errorphant_offline, R.string.error_network ) { adapter.retry() } } else -> { binding.statusView.setup( - R.drawable.elephant_error, + R.drawable.errorphant_error, R.string.error_generic ) { adapter.retry() } } @@ -439,10 +420,17 @@ class NotificationsFragment : override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.fragment_notifications, menu) + val iconColor = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) menu.findItem(R.id.action_refresh)?.apply { icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { sizeDp = 20 - colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) + colorInt = iconColor + } + } + menu.findItem(R.id.action_edit_notification_filter)?.apply { + icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_tune).apply { + sizeDp = 20 + colorInt = iconColor } } } @@ -458,6 +446,14 @@ class NotificationsFragment : viewModel.accept(InfallibleUiAction.LoadNewest) true } + R.id.action_edit_notification_filter -> { + showFilterDialog() + true + } + R.id.action_clear_notifications -> { + confirmClearNotifications() + true + } else -> false } } @@ -518,7 +514,11 @@ class NotificationsFragment : override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { val status = adapter.peek(position)?.statusViewData?.status ?: return - super.viewMedia(attachmentIndex, list(status), view) + super.viewMedia( + attachmentIndex, + list(status, viewModel.statusDisplayOptions.value.showSensitiveMedia), + view + ) } override fun onViewThread(position: Int) { @@ -621,7 +621,6 @@ class NotificationsFragment : override fun onReselect() { if (isAdded) { - binding.appBarOptions.setExpanded(true, false) layoutManager.scrollToPosition(0) } } 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 index faa1aefce..8f45a56f2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt @@ -124,7 +124,7 @@ class NotificationsPagingAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(parent.context) - return when (NotificationViewKind.values()[viewType]) { + return when (NotificationViewKind.entries[viewType]) { NotificationViewKind.STATUS -> { StatusViewHolder( ItemStatusBinding.inflate(inflater, parent, false), 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 index d1d41a947..d06a8bbcf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -46,7 +46,6 @@ import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.NotificationViewData import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -70,29 +69,23 @@ import kotlinx.coroutines.rx3.await import retrofit2.HttpException import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.ExperimentalTime data class UiState( /** Filtered notification types */ val activeFilter: Set = emptySet(), - /** True if the UI to filter and clear notifications should be shown */ - val showFilterOptions: Boolean = false, - /** True if the FAB should be shown while scrolling */ val showFabWhileScrolling: Boolean = true ) /** Preferences the UI reacts to */ data class UiPrefs( - val showFabWhileScrolling: Boolean, - val showFilter: Boolean + val showFabWhileScrolling: Boolean ) { companion object { /** Relevant preference keys. Changes to any of these trigger a display update */ val prefKeys = setOf( - PrefKeys.FAB_HIDE, - PrefKeys.SHOW_NOTIFICATIONS_FILTER + PrefKeys.FAB_HIDE ) } } @@ -103,7 +96,7 @@ sealed class UiAction /** Actions the user can trigger from the UI. These actions may fail. */ sealed class FallibleUiAction : UiAction() { /** Clear all notifications */ - object ClearNotifications : FallibleUiAction() + data object ClearNotifications : FallibleUiAction() } /** @@ -129,7 +122,7 @@ sealed class InfallibleUiAction : UiAction() { // Resets the account's `lastNotificationId`, which can't fail, which is why this is // infallible. Reloading the data may fail, but that's handled by the paging system / // adapter refresh logic. - object LoadNewest : InfallibleUiAction() + data object LoadNewest : InfallibleUiAction() } /** Actions the user can trigger on an individual notification. These may fail. */ @@ -146,13 +139,13 @@ sealed class UiSuccess { // of these three should trigger the UI to refresh. /** A user was blocked */ - object Block : UiSuccess() + data object Block : UiSuccess() /** A user was muted */ - object Mute : UiSuccess() + data object Mute : UiSuccess() /** A conversation was muted */ - object MuteConversation : UiSuccess() + data object MuteConversation : UiSuccess() } /** The result of a successful action on a notification */ @@ -286,7 +279,7 @@ sealed class UiError( } } -@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class, ExperimentalTime::class) +@OptIn(ExperimentalCoroutinesApi::class) class NotificationsViewModel @Inject constructor( private val repository: NotificationsRepository, private val preferences: SharedPreferences, @@ -497,7 +490,6 @@ class NotificationsViewModel @Inject constructor( uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs -> UiState( activeFilter = filter.filter, - showFilterOptions = prefs.showFilter, showFabWhileScrolling = prefs.showFabWhileScrolling ) }.stateIn( @@ -546,8 +538,7 @@ class NotificationsViewModel @Inject constructor( .onStart { emit(toPrefs()) } private fun toPrefs() = UiPrefs( - showFabWhileScrolling = !preferences.getBoolean(PrefKeys.FAB_HIDE, false), - showFilter = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true) + showFabWhileScrolling = !preferences.getBoolean(PrefKeys.FAB_HIDE, false) ) companion object { 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 b47df1596..f8464ea02 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 @@ -34,6 +34,7 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.databinding.ActivityPreferencesBinding import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME import com.keylesspalace.tusky.util.APP_THEME_DEFAULT import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.setAppNightMode @@ -145,8 +146,8 @@ class PreferencesActivity : override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { when (key) { - "appTheme" -> { - val theme = sharedPreferences.getNonNullString("appTheme", APP_THEME_DEFAULT) + APP_THEME -> { + val theme = sharedPreferences.getNonNullString(APP_THEME, APP_THEME_DEFAULT) Log.d("activeTheme", theme) setAppNightMode(theme) 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 e2d29d495..f6541f1fd 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 @@ -208,13 +208,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { 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 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 023905291..375922b3c 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,9 +19,9 @@ import android.os.Bundle import androidx.preference.PreferenceFragmentCompat import com.keylesspalace.tusky.R import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.settings.checkBoxPreference import com.keylesspalace.tusky.settings.makePreferenceScreen import com.keylesspalace.tusky.settings.preferenceCategory +import com.keylesspalace.tusky.settings.switchPreference class TabFilterPreferencesFragment : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -29,14 +29,14 @@ class TabFilterPreferencesFragment : PreferenceFragmentCompat() { preferenceCategory(R.string.title_home) { category -> category.isIconSpaceReserved = false - checkBoxPreference { + switchPreference { setTitle(R.string.pref_title_show_boosts) key = PrefKeys.TAB_FILTER_HOME_BOOSTS setDefaultValue(true) isIconSpaceReserved = false } - checkBoxPreference { + switchPreference { setTitle(R.string.pref_title_show_replies) key = PrefKeys.TAB_FILTER_HOME_REPLIES setDefaultValue(true) 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 176fb8079..01b69d38b 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 @@ -40,7 +40,7 @@ import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import javax.inject.Inject -class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider { +class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider, SearchView.OnQueryTextListener { @Inject lateinit var androidInjector: DispatchingAndroidInjector @@ -91,8 +91,6 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider { searchViewMenuItem.expandActionView() val searchView = searchViewMenuItem.actionView as SearchView setupSearchView(searchView) - - searchView.setQuery(viewModel.currentQuery, false) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { @@ -150,9 +148,23 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider { val pxBuffer = ((48 * 2) * resources.displayMetrics.density).toInt() searchView.maxWidth = pxScreenWidth - pxBuffer + // Keep text that was entered also when switching to a different tab (before the search is executed) + searchView.setOnQueryTextListener(this) + searchView.setQuery(viewModel.currentSearchFieldContent ?: "", false) + searchView.requestFocus() } + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + viewModel.currentSearchFieldContent = newText + + return false + } + override fun androidInjector() = androidInjector companion object { 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 a43776ddc..0ab4d1e1c 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 @@ -45,6 +45,7 @@ class SearchViewModel @Inject constructor( ) : ViewModel() { var currentQuery: String = "" + var currentSearchFieldContent: String? = null val activeAccount: AccountEntity? get() = accountManager.activeAccount diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/util/TimelineUtils.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/util/TimelineUtils.kt index c8d95fd81..4a1d75f92 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/util/TimelineUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/util/TimelineUtils.kt @@ -1,9 +1,10 @@ package com.keylesspalace.tusky.components.timeline.util +import com.google.gson.JsonParseException import retrofit2.HttpException import java.io.IOException -fun Throwable.isExpected() = this is IOException || this is HttpException +fun Throwable.isExpected() = this is IOException || this is HttpException || this is JsonParseException inline fun ifExpected( t: Throwable, 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 89afefecd..2746b1acf 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 @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.components.timeline.viewmodel +import android.util.Log import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState @@ -117,6 +118,7 @@ class CachedTimelineRemoteMediator( return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) } catch (e: Exception) { return ifExpected(e) { + Log.w(TAG, "Failed to load timeline", e) MediatorResult.Error(e) } } @@ -175,4 +177,8 @@ class CachedTimelineRemoteMediator( } return overlappedStatuses } + + companion object { + private const val TAG = "CachedTimelineRM" + } } 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 98da15bea..40b475e06 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 @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.components.timeline.viewmodel +import android.util.Log import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState @@ -106,8 +107,13 @@ class NetworkTimelineRemoteMediator( return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) } catch (e: Exception) { return ifExpected(e) { + Log.w(TAG, "Failed to load timeline", e) MediatorResult.Error(e) } } } + + companion object { + private const val TAG = "NetworkTimelineRM" + } } 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 3270e9c26..bdf2cdc79 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 @@ -48,7 +48,7 @@ class TrendingActivity : BaseActivity(), HasAndroidInjector { if (supportFragmentManager.findFragmentById(R.id.fragmentContainer) == null) { supportFragmentManager.commit { - val fragment = TrendingFragment.newInstance() + val fragment = TrendingTagsFragment.newInstance() replace(R.id.fragmentContainer, fragment) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsAdapter.kt similarity index 99% rename from app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsAdapter.kt index b40d67670..4137d200e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsAdapter.kt @@ -24,7 +24,7 @@ import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding import com.keylesspalace.tusky.databinding.ItemTrendingDateBinding import com.keylesspalace.tusky.viewdata.TrendingViewData -class TrendingAdapter( +class TrendingTagsAdapter( private val onViewTag: (String) -> Unit ) : ListAdapter(TrendingDifferCallback) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsFragment.kt similarity index 85% rename from app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt rename to app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsFragment.kt index bcb22d050..c04caee6d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsFragment.kt @@ -33,8 +33,8 @@ import at.connyduck.sparkbutton.helpers.Utils import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity -import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel -import com.keylesspalace.tusky.databinding.FragmentTrendingBinding +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 @@ -48,8 +48,8 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import javax.inject.Inject -class TrendingFragment : - Fragment(R.layout.fragment_trending), +class TrendingTagsFragment : + Fragment(R.layout.fragment_trending_tags), OnRefreshListener, Injectable, ReselectableFragment, @@ -58,11 +58,11 @@ class TrendingFragment : @Inject lateinit var viewModelFactory: ViewModelFactory - private val viewModel: TrendingViewModel by viewModels { viewModelFactory } + private val viewModel: TrendingTagsViewModel by viewModels { viewModelFactory } - private val binding by viewBinding(FragmentTrendingBinding::bind) + private val binding by viewBinding(FragmentTrendingTagsBinding::bind) - private val adapter = TrendingAdapter(::onViewTag) + private val adapter = TrendingTagsAdapter(::onViewTag) override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) @@ -111,8 +111,8 @@ class TrendingFragment : spanSizeLookup = object : SpanSizeLookup() { override fun getSpanSize(position: Int): Int { return when (adapter.getItemViewType(position)) { - TrendingAdapter.VIEW_TYPE_HEADER -> columnCount - TrendingAdapter.VIEW_TYPE_TAG -> 1 + TrendingTagsAdapter.VIEW_TYPE_HEADER -> columnCount + TrendingTagsAdapter.VIEW_TYPE_TAG -> 1 else -> -1 } } @@ -139,15 +139,15 @@ class TrendingFragment : (requireActivity() as BaseActivity).startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag)) } - private fun processViewState(uiState: TrendingViewModel.TrendingUiState) { + private fun processViewState(uiState: TrendingTagsViewModel.TrendingTagsUiState) { Log.d(TAG, uiState.loadingState.name) when (uiState.loadingState) { - TrendingViewModel.LoadingState.INITIAL -> clearLoadingState() - TrendingViewModel.LoadingState.LOADING -> applyLoadingState() - TrendingViewModel.LoadingState.REFRESHING -> applyRefreshingState() - TrendingViewModel.LoadingState.LOADED -> applyLoadedState(uiState.trendingViewData) - TrendingViewModel.LoadingState.ERROR_NETWORK -> networkError() - TrendingViewModel.LoadingState.ERROR_OTHER -> otherError() + TrendingTagsViewModel.LoadingState.INITIAL -> clearLoadingState() + TrendingTagsViewModel.LoadingState.LOADING -> applyLoadingState() + TrendingTagsViewModel.LoadingState.REFRESHING -> applyRefreshingState() + TrendingTagsViewModel.LoadingState.LOADED -> applyLoadedState(uiState.trendingViewData) + TrendingTagsViewModel.LoadingState.ERROR_NETWORK -> networkError() + TrendingTagsViewModel.LoadingState.ERROR_OTHER -> otherError() } } @@ -194,7 +194,7 @@ class TrendingFragment : binding.swipeRefreshLayout.isRefreshing = false binding.messageView.setup( - R.drawable.elephant_offline, + R.drawable.errorphant_offline, R.string.error_network ) { refreshContent() } } @@ -206,7 +206,7 @@ class TrendingFragment : binding.swipeRefreshLayout.isRefreshing = false binding.messageView.setup( - R.drawable.elephant_error, + R.drawable.errorphant_error, R.string.error_generic ) { refreshContent() } } @@ -247,8 +247,8 @@ class TrendingFragment : } companion object { - private const val TAG = "TrendingFragment" + private const val TAG = "TrendingTagsFragment" - fun newInstance() = TrendingFragment() + fun newInstance() = TrendingTagsFragment() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt similarity index 66% rename from app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt rename to app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt index d1877a2fc..92f0a8c46 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt @@ -35,7 +35,7 @@ import kotlinx.coroutines.launch import java.io.IOException import javax.inject.Inject -class TrendingViewModel @Inject constructor( +class TrendingTagsViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub ) : ViewModel() { @@ -43,13 +43,13 @@ class TrendingViewModel @Inject constructor( INITIAL, LOADING, REFRESHING, LOADED, ERROR_NETWORK, ERROR_OTHER } - data class TrendingUiState( + data class TrendingTagsUiState( val trendingViewData: List, val loadingState: LoadingState ) - val uiState: Flow get() = _uiState - private val _uiState = MutableStateFlow(TrendingUiState(listOf(), LoadingState.INITIAL)) + val uiState: Flow get() = _uiState + private val _uiState = MutableStateFlow(TrendingTagsUiState(listOf(), LoadingState.INITIAL)) init { invalidate() @@ -73,38 +73,42 @@ class TrendingViewModel @Inject constructor( */ fun invalidate(refresh: Boolean = false) = viewModelScope.launch { if (refresh) { - _uiState.value = TrendingUiState(emptyList(), LoadingState.REFRESHING) + _uiState.value = TrendingTagsUiState(emptyList(), LoadingState.REFRESHING) } else { - _uiState.value = TrendingUiState(emptyList(), LoadingState.LOADING) + _uiState.value = TrendingTagsUiState(emptyList(), LoadingState.LOADING) } val deferredFilters = async { mastodonApi.getFilters() } mastodonApi.trendingTags().fold( { tagResponse -> - val homeFilters = deferredFilters.await().getOrNull()?.filter { filter -> - filter.context.contains(Filter.Kind.HOME.kind) - } - val tags = tagResponse - .filter { tag -> - homeFilters?.none { filter -> - filter.keywords.any { keyword -> keyword.keyword.equals(tag.name, ignoreCase = true) } - } ?: false + + val firstTag = tagResponse.firstOrNull() + _uiState.value = if (firstTag == null) { + TrendingTagsUiState(emptyList(), LoadingState.LOADED) + } else { + val homeFilters = deferredFilters.await().getOrNull()?.filter { filter -> + filter.context.contains(Filter.Kind.HOME.kind) } - .sortedByDescending { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } } - .toViewData() + val tags = tagResponse + .filter { tag -> + homeFilters?.none { filter -> + filter.keywords.any { keyword -> keyword.keyword.equals(tag.name, ignoreCase = true) } + } ?: false + } + .sortedByDescending { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } } + .toViewData() - val firstTag = tagResponse.first() - val header = TrendingViewData.Header(firstTag.start(), firstTag.end()) - - _uiState.value = TrendingUiState(listOf(header) + tags, LoadingState.LOADED) + val header = TrendingViewData.Header(firstTag.start(), firstTag.end()) + TrendingTagsUiState(listOf(header) + tags, LoadingState.LOADED) + } }, { error -> Log.w(TAG, "failed loading trending tags", error) if (error is IOException) { - _uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK) + _uiState.value = TrendingTagsUiState(emptyList(), LoadingState.ERROR_NETWORK) } else { - _uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_OTHER) + _uiState.value = TrendingTagsUiState(emptyList(), LoadingState.ERROR_OTHER) } } ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ConversationLineItemDecoration.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ConversationLineItemDecoration.kt index 36e36d10f..ffd1b9c83 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ConversationLineItemDecoration.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ConversationLineItemDecoration.kt @@ -24,36 +24,34 @@ import androidx.core.view.forEach import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R -class ConversationLineItemDecoration(private val context: Context) : RecyclerView.ItemDecoration() { - +class ConversationLineItemDecoration(context: Context) : RecyclerView.ItemDecoration() { private val divider: Drawable = ContextCompat.getDrawable(context, R.drawable.conversation_thread_line)!! - override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { - val dividerStart = parent.paddingStart + context.resources.getDimensionPixelSize(R.dimen.status_line_margin_start) - val dividerEnd = dividerStart + divider.intrinsicWidth + private val avatarTopMargin = context.resources.getDimensionPixelSize(R.dimen.account_avatar_margin) + private val halfAvatarHeight = context.resources.getDimensionPixelSize(R.dimen.timeline_status_avatar_height) / 2 + private val statusLineMarginStart = context.resources.getDimensionPixelSize(R.dimen.status_line_margin_start) - val avatarMargin = context.resources.getDimensionPixelSize(R.dimen.account_avatar_margin) + override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { + val dividerStart = parent.paddingStart + statusLineMarginStart + val dividerEnd = dividerStart + divider.intrinsicWidth val items = (parent.adapter as ThreadAdapter).currentList - parent.forEach { child -> + parent.forEach { statusItemView -> + val position = parent.getChildAdapterPosition(statusItemView) - val position = parent.getChildAdapterPosition(child) - - val current = items.getOrNull(position) - - if (current != null) { + items.getOrNull(position)?.let { current -> val above = items.getOrNull(position - 1) val dividerTop = if (above != null && above.id == current.status.inReplyToId) { - child.top + statusItemView.top } else { - child.top + avatarMargin + statusItemView.top + avatarTopMargin + halfAvatarHeight } val below = items.getOrNull(position + 1) val dividerBottom = if (below != null && current.id == below.status.inReplyToId && !current.isDetailed) { - child.bottom + statusItemView.bottom } else { - child.top + avatarMargin + statusItemView.top + avatarTopMargin + halfAvatarHeight } if (parent.layoutDirection == View.LAYOUT_DIRECTION_LTR) { 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 0d4ba3b35..d7541f428 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 @@ -336,7 +336,11 @@ class ViewThreadFragment : override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { val status = adapter.currentList[position].status - super.viewMedia(attachmentIndex, list(status), view) + super.viewMedia( + attachmentIndex, + list(status, alwaysShowSensitiveMedia), + view + ) } override fun onViewThread(position: Int) { 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 f4b94400b..da8a91e4d 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 @@ -516,7 +516,7 @@ class ViewThreadViewModel @Inject constructor( sealed interface ThreadUiState { /** The initial load of the detailed status for this thread */ - object Loading : ThreadUiState + data object Loading : ThreadUiState /** Loading the detailed status has completed, now loading ancestors/descendants */ data class LoadingThread( @@ -535,7 +535,7 @@ sealed interface ThreadUiState { ) : ThreadUiState /** Refreshing the thread with a swipe */ - object Refreshing : ThreadUiState + data object Refreshing : ThreadUiState } enum class RevealButtonState { 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 33085f82b..56bf1ffc4 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 @@ -51,10 +51,10 @@ class ViewEditsAdapter( private val absoluteTimeFormatter = AbsoluteTimeFormatter() /** Size of large text in this theme, in px */ - var largeTextSizePx: Float = 0f + private var largeTextSizePx: Float = 0f /** Size of medium text in this theme, in px */ - var mediumTextSizePx: Float = 0f + private var mediumTextSizePx: Float = 0f override fun onCreateViewHolder( parent: ViewGroup, 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 93f663587..85786efa2 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 @@ -132,12 +132,12 @@ class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : Vie } sealed interface EditsUiState { - object Initial : EditsUiState - object Loading : EditsUiState + data object Initial : EditsUiState + data object Loading : EditsUiState // "Refreshing" state is necessary, otherwise a refresh state transition is Success -> Success, // and state flows don't emit repeated states, so the UI never updates. - object Refreshing : EditsUiState + data object Refreshing : EditsUiState class Error(val throwable: Throwable) : EditsUiState data class Success( val edits: List diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt index cdde765f7..84c8874f5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -100,7 +100,11 @@ data class AccountEntity( * ID of the status at the top of the visible list in the home timeline when the * user navigated away. */ - var lastVisibleHomeTimelineStatusId: String? = null + 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 ) { val identifier: String @@ -125,9 +129,7 @@ data class AccountEntity( other as AccountEntity if (id == other.id) return true - if (domain == other.domain && accountId == other.accountId) return true - - return false + return domain == other.domain && accountId == other.accountId } override fun hashCode(): Int { 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 61ce076a5..9c7999fd9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -156,6 +156,7 @@ class AccountManager @Inject constructor(db: AppDatabase) { 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) 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 86ea7664b..d5e8c3e99 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.db; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.room.AutoMigration; import androidx.room.Database; import androidx.room.DeleteColumn; @@ -41,20 +42,21 @@ import java.io.File; TimelineAccountEntity.class, ConversationEntity.class }, - version = 51, + version = 53, autoMigrations = { @AutoMigration(from = 48, to = 49), @AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class), - @AutoMigration(from = 50, to = 51) + @AutoMigration(from = 50, to = 51), + @AutoMigration(from = 51, to = 52), } ) public abstract class AppDatabase extends RoomDatabase { - public abstract AccountDao accountDao(); - public abstract InstanceDao instanceDao(); - public abstract ConversationsDao conversationDao(); - public abstract TimelineDao timelineDao(); - public abstract DraftDao draftDao(); + @NonNull public abstract AccountDao accountDao(); + @NonNull public abstract InstanceDao instanceDao(); + @NonNull public abstract ConversationsDao conversationDao(); + @NonNull public abstract TimelineDao timelineDao(); + @NonNull public abstract DraftDao draftDao(); public static final Migration MIGRATION_2_3 = new Migration(2, 3) { @Override @@ -386,7 +388,7 @@ public abstract class AppDatabase extends RoomDatabase { private final File oldDraftDirectory; - public Migration25_26(File oldDraftDirectory) { + public Migration25_26(@Nullable File oldDraftDirectory) { super(25, 26); this.oldDraftDirectory = oldDraftDirectory; } @@ -672,4 +674,15 @@ public abstract class AppDatabase extends RoomDatabase { @DeleteColumn(tableName = "AccountEntity", columnName = "activeNotifications") static class MIGRATION_49_50 implements AutoMigrationSpec { } + + /** + * TabData.TRENDING was renamed to TabData.TRENDING_TAGS, and the text + * representation was changed from "Trending" to "TrendingTags". + */ + public static final Migration MIGRATION_52_53 = new Migration(52, 53) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("UPDATE `AccountEntity` SET `tabpreferences` = REPLACE(tabpreferences, 'Trending:', 'TrendingTags:')"); + } + }; } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt index 02b4f6ea7..f60c78f8a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -94,7 +94,7 @@ abstract class ActivitiesModule { @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) abstract fun contributesPreferencesActivity(): PreferencesActivity - @ContributesAndroidInjector + @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) abstract fun contributesViewMediaActivity(): ViewMediaActivity @ContributesAndroidInjector diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt index bc2c7d753..0f65d6c25 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -68,7 +68,7 @@ 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_47_48, AppDatabase.MIGRATION_52_53 ) .build() } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index 3292bbfe8..710ab75af 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -32,16 +32,13 @@ import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragmen 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.TrendingFragment +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.ViewVideoFragment import dagger.Module import dagger.android.ContributesAndroidInjector -/** - * Created by charlag on 3/24/18. - */ - @Module abstract class FragmentBuildersModule { @ContributesAndroidInjector @@ -102,5 +99,8 @@ abstract class FragmentBuildersModule { abstract fun listsForAccountFragment(): ListsForAccountFragment @ContributesAndroidInjector - abstract fun trendingFragment(): TrendingFragment + abstract fun trendingTagsFragment(): TrendingTagsFragment + + @ContributesAndroidInjector + abstract fun viewVideoFragment(): ViewVideoFragment } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index f2ae58893..0ac5ae54e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -39,7 +39,7 @@ 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.TrendingViewModel +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 @@ -173,8 +173,8 @@ abstract class ViewModelModule { @Binds @IntoMap - @ViewModelKey(TrendingViewModel::class) - internal abstract fun trendingViewModel(viewModel: TrendingViewModel): ViewModel + @ViewModelKey(TrendingTagsViewModel::class) + internal abstract fun trendingTagsViewModel(viewModel: TrendingTagsViewModel): ViewModel @Binds @IntoMap diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt b/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt index bfec7cc52..a74337ef5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt @@ -22,5 +22,6 @@ package com.keylesspalace.tusky.entity data class MastoList( val id: String, - val title: String + val title: String, + val exclusive: Boolean? ) 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 24c8feb03..bbbfdf219 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt @@ -19,25 +19,31 @@ 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 +import android.view.GestureDetector import android.view.LayoutInflater 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 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.github.chrisbanes.photoview.PhotoViewAttacher +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.viewBinding import com.keylesspalace.tusky.util.visible +import com.ortiz.touchview.OnTouchCoordinatesListener +import com.ortiz.touchview.TouchImageView import io.reactivex.rxjava3.subjects.BehaviorSubject import kotlin.math.abs @@ -48,10 +54,8 @@ class ViewImageFragment : ViewMediaFragment() { fun onPhotoTap() } - private var _binding: FragmentViewImageBinding? = null - private val binding get() = _binding!! + private val binding by viewBinding(FragmentViewImageBinding::bind) - private lateinit var attacher: PhotoViewAttacher private lateinit var photoActionsListener: PhotoActionsListener private lateinit var toolbar: View private var transition = BehaviorSubject.create() @@ -84,8 +88,7 @@ class ViewImageFragment : ViewMediaFragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { toolbar = (requireActivity() as ViewMediaActivity).toolbar this.transition = BehaviorSubject.create() - _binding = FragmentViewImageBinding.inflate(inflater, container, false) - return binding.root + return inflater.inflate(R.layout.fragment_view_image, container, false) } @SuppressLint("ClickableViewAccessibility") @@ -108,95 +111,139 @@ class ViewImageFragment : ViewMediaFragment() { } } - attacher = PhotoViewAttacher(binding.photoView).apply { - // This prevents conflicts with ViewPager - setAllowParentInterceptOnEdge(true) + val singleTapDetector = GestureDetectorCompat( + requireContext(), + object : GestureDetector.SimpleOnGestureListener() { + override fun onDown(e: MotionEvent) = true + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + photoActionsListener.onPhotoTap() + return false + } + } + ) - // Clicking outside the photo closes the viewer. - setOnOutsidePhotoTapListener { photoActionsListener.onDismiss() } - setOnClickListener { onMediaTap() } + binding.photoView.setOnTouchCoordinatesListener(object : OnTouchCoordinatesListener { + /** Y coordinate of the last single-finger drag */ + var lastDragY: Float? = null - /* A vertical swipe motion also closes the viewer. This is especially useful when the photo - * mostly fills the screen so clicking outside is difficult. */ - setOnSingleFlingListener { _, _, velocityX, velocityY -> - var result = false - if (abs(velocityY) > abs(velocityX)) { + override fun onTouchCoordinate(view: View, event: MotionEvent, bitmapPoint: PointF) { + singleTapDetector.onTouchEvent(event) + + // Two fingers have gone down after a single finger drag. Finish the drag + if (event.pointerCount == 2 && lastDragY != null) { + onGestureEnd(view) + lastDragY = null + } + + // Stop the parent view from handling touches if either (a) the user has 2+ + // fingers on the screen, or (b) the image has been zoomed in, and can be scrolled + // horizontally in both directions. + // + // This stops things like ViewPager2 from trying to intercept a left/right swipe + // and ensures that the image does not appear to "stick" to the screen as different + // views fight over who should be handling the swipe. + // + // If the view can be scrolled in one direction it's OK to let the parent intercept, + // which allows the user to swipe between images even if one or more of them have + // been zoomed in. + if (event.pointerCount >= 2 || view.canScrollHorizontally(1) && view.canScrollHorizontally(-1)) { + when (event.action) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { + view.parent.requestDisallowInterceptTouchEvent(true) + } + + MotionEvent.ACTION_UP -> { + view.parent.requestDisallowInterceptTouchEvent(false) + } + } + return + } + + // The user is dragging the image around + if (event.pointerCount == 1) { + // If the image is zoomed then the swipe-to-dismiss functionality is disabled + if ((view as TouchImageView).isZoomed) return + + // The user's finger just went down, start recording where they are dragging from + if (event.action == MotionEvent.ACTION_DOWN) { + lastDragY = event.rawY + return + } + + // The user is dragging the un-zoomed image to possibly fling it up or down + // to dismiss. + if (event.action == MotionEvent.ACTION_MOVE) { + // lastDragY may be null; e.g., the user was performing a two-finger drag, + // and has lifted one finger. In this case do nothing + lastDragY ?: return + + // Compute the Y offset of the drag, and scale/translate the photoview + // accordingly. + val diff = event.rawY - lastDragY!! + if (view.translationY != 0f || abs(diff) > 40) { + // Drag has definitely started, stop the parent from interfering + view.parent.requestDisallowInterceptTouchEvent(true) + view.translationY += diff + val scale = (-abs(view.translationY) / 720 + 1).coerceAtLeast(0.5f) + view.scaleY = scale + view.scaleX = scale + lastDragY = event.rawY + } + return + } + + // The user has finished dragging. Allow the parent to handle touch events if + // appropriate, and end the gesture. + if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { + view.parent.requestDisallowInterceptTouchEvent(false) + if (lastDragY != null) onGestureEnd(view) + lastDragY = null + return + } + } + } + + /** + * Handle the end of the user's gesture. + * + * If the user was previously dragging, and the image has been dragged a sufficient + * distance then we are done. Otherwise, animate the image back to its starting position. + */ + private fun onGestureEnd(view: View) { + if (abs(view.translationY) > 180) { photoActionsListener.onDismiss() - result = true + } else { + view.animate().translationY(0f).scaleX(1f).start() } - result } - } - - var lastY = 0f - - binding.photoView.setOnTouchListener { v, event -> - // This part is for scaling/translating on vertical move. - // We use raw coordinates to get the correct ones during scaling - - if (event.action == MotionEvent.ACTION_DOWN) { - lastY = event.rawY - } else if (event.pointerCount == 1 && - attacher.scale == 1f && - event.action == MotionEvent.ACTION_MOVE - ) { - val diff = event.rawY - lastY - // This code is to prevent transformations during page scrolling - // If we are already translating or we reached the threshold, then transform. - if (binding.photoView.translationY != 0f || abs(diff) > 40) { - binding.photoView.translationY += (diff) - val scale = (-abs(binding.photoView.translationY) / 720 + 1).coerceAtLeast(0.5f) - binding.photoView.scaleY = scale - binding.photoView.scaleX = scale - lastY = event.rawY - return@setOnTouchListener true - } - } else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { - onGestureEnd() - } - attacher.onTouch(v, event) - } + }) finalizeViewSetup(url, attachment?.previewUrl, description) } - private fun onGestureEnd() { - if (_binding == null) { - return - } - if (abs(binding.photoView.translationY) > 180) { - photoActionsListener.onDismiss() - } else { - binding.photoView.animate().translationY(0f).scaleX(1f).scaleY(1f).start() - } - } - - private fun onMediaTap() { - photoActionsListener.onPhotoTap() - } - override fun onToolbarVisibilityChange(visible: Boolean) { - if (_binding == null || !userVisibleHint) { - return - } + if (!userVisibleHint) return + isDescriptionVisible = showingDescription && visible val alpha = if (isDescriptionVisible) 1.0f else 0.0f binding.captionSheet.animate().alpha(alpha) .setListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { - if (_binding != null) { - binding.captionSheet.visible(isDescriptionVisible) - } + view ?: return + binding.captionSheet.visible(isDescriptionVisible) animation.removeListener(this) } }) .start() } - override fun onDestroyView() { + override fun onStop() { + super.onStop() Glide.with(this).clear(binding.photoView) + } + + override fun onDestroyView() { transition.onComplete() - _binding = null super.onDestroyView() } @@ -270,7 +317,7 @@ class ViewImageFragment : ViewMediaFragment() { photoActionsListener.onBringUp() } // Hide progress bar only on fail request from internet - if (!isCacheRequest && _binding != null) binding.progressBar.hide() + if (!isCacheRequest) binding.progressBar.hide() // We don't want to overwrite preview with null when main image fails to load return !isCacheRequest } @@ -283,9 +330,7 @@ class ViewImageFragment : ViewMediaFragment() { dataSource: DataSource, isFirstResource: Boolean ): Boolean { - if (_binding != null) { - binding.progressBar.hide() // Always hide the progress bar on success - } + binding.progressBar.hide() // Always hide the progress bar on success if (!startedTransition || !shouldStartTransition) { // Set this right away so that we don't have to concurrent post() requests @@ -303,10 +348,6 @@ class ViewImageFragment : ViewMediaFragment() { .take(1) .subscribe { target.onResourceReady(resource, null) - // It's needed. Don't ask why, I don't know, setImageDrawable() should - // do it by itself but somehow it doesn't work automatically. - // Just do it. If you don't, image will jump around when touched. - attacher.update() } } return true 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 2f8aaf1d9..3125e556e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt @@ -17,7 +17,9 @@ package com.keylesspalace.tusky.fragment import android.os.Bundle import android.text.TextUtils +import androidx.annotation.OptIn import androidx.fragment.app.Fragment +import androidx.media3.common.util.UnstableApi import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.entity.Attachment @@ -47,6 +49,7 @@ abstract class ViewMediaFragment : Fragment() { protected val ARG_SINGLE_IMAGE_URL = "singleImageUrl" @JvmStatic + @OptIn(UnstableApi::class) fun newInstance(attachment: Attachment, shouldStartPostponedTransition: Boolean): ViewMediaFragment { val arguments = Bundle(2) arguments.putParcelable(ARG_ATTACHMENT, attachment) 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 68dc6687a..6cd13c076 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt @@ -19,33 +19,60 @@ import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.annotation.SuppressLint import android.content.Context +import android.graphics.drawable.Drawable +import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper import android.text.method.ScrollingMovementMethod import android.view.GestureDetector -import android.view.KeyEvent +import android.view.Gravity import android.view.LayoutInflater import android.view.MotionEvent import android.view.View import android.view.ViewGroup -import android.widget.MediaController +import android.widget.FrameLayout +import android.widget.LinearLayout +import androidx.annotation.OptIn import androidx.core.view.GestureDetectorCompat +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.util.EventLogger +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerControlView +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +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.viewBinding import com.keylesspalace.tusky.util.visible -import com.keylesspalace.tusky.view.ExposedPlayPauseVideoView +import okhttp3.OkHttpClient +import javax.inject.Inject import kotlin.math.abs -class ViewVideoFragment : ViewMediaFragment() { +@UnstableApi +class ViewVideoFragment : ViewMediaFragment(), Injectable { interface VideoActionsListener { fun onDismiss() } - private var _binding: FragmentViewVideoBinding? = null - private val binding get() = _binding!! + @Inject + lateinit var okHttpClient: OkHttpClient + + private val binding by viewBinding(FragmentViewVideoBinding::bind) private lateinit var videoActionsListener: VideoActionsListener private lateinit var toolbar: View @@ -54,39 +81,266 @@ class ViewVideoFragment : ViewMediaFragment() { // 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() - mediaController.hide() } private lateinit var mediaActivity: ViewMediaActivity - private lateinit var mediaController: MediaController + private lateinit var mediaPlayerListener: Player.Listener private var isAudio = false - companion object { - private const val TOOLBAR_HIDE_DELAY_MS = 3000L - } + private lateinit var mediaAttachment: Attachment + + private var player: ExoPlayer? = null + + /** The saved seek position, if the fragment is being resumed */ + private var savedSeekPosition: Long = 0 + + private lateinit var mediaSourceFactory: DefaultMediaSourceFactory override fun onAttach(context: Context) { super.onAttach(context) + + mediaSourceFactory = DefaultMediaSourceFactory(context) + .setDataSourceFactory(DefaultDataSource.Factory(context, OkHttpDataSource.Factory(okHttpClient))) + 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 + val controls = rootView.findViewById(androidx.media3.ui.R.id.exo_center_controls) + val layoutParams = controls.layoutParams as FrameLayout.LayoutParams + layoutParams.gravity = Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM + layoutParams.bottomMargin = rootView.context.resources.getDimension(androidx.media3.ui.R.dimen.exo_styled_bottom_bar_height) + .toInt() + controls.layoutParams = layoutParams + + return rootView + } + + @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 + + /** + * Handle single taps, flings, and dragging + */ + val touchListener = object : View.OnTouchListener { + var lastY = 0f + + /** The view that contains the playing content */ + // binding.videoView is fullscreen, and includes the controls, so don't use that + // when scaling in response to the user dragging on the screen + val contentFrame = binding.videoView.findViewById(androidx.media3.ui.R.id.exo_content_frame) + + /** Handle taps and flings */ + val simpleGestureDetector = GestureDetectorCompat( + requireContext(), + object : GestureDetector.SimpleOnGestureListener() { + override fun onDown(e: MotionEvent) = true + + /** A single tap should show/hide the media description */ + override fun onSingleTapUp(e: MotionEvent): Boolean { + mediaActivity.onPhotoTap() + return false + } + + /** A fling up/down should dismiss the fragment */ + override fun onFling( + e1: MotionEvent, + e2: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + if (abs(velocityY) > abs(velocityX)) { + videoActionsListener.onDismiss() + return true + } + return false + } + } + ) + + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(v: View?, event: MotionEvent): Boolean { + // Track movement, and scale / translate the video display accordingly + if (event.action == MotionEvent.ACTION_DOWN) { + lastY = event.rawY + } else if (event.pointerCount == 1 && event.action == MotionEvent.ACTION_MOVE) { + val diff = event.rawY - lastY + if (contentFrame.translationY != 0f || abs(diff) > 40) { + contentFrame.translationY += diff + val scale = (-abs(contentFrame.translationY) / 720 + 1).coerceAtLeast(0.5f) + contentFrame.scaleY = scale + contentFrame.scaleX = scale + lastY = event.rawY + } + } else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { + if (abs(contentFrame.translationY) > 180) { + videoActionsListener.onDismiss() + } else { + contentFrame.animate().translationY(0f).scaleX(1f).scaleY(1f).start() + } + } + + simpleGestureDetector.onTouchEvent(event) + + // Allow the player's normal onTouch handler to run as well (e.g., to show the + // player controls on tap) + return false + } + } + + mediaPlayerListener = object : Player.Listener { + @SuppressLint("ClickableViewAccessibility", "SyntheticAccessor") + @OptIn(UnstableApi::class) + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_READY -> { + // Wait until the media is loaded before accepting taps as we don't want toolbar to + // be hidden until then. + binding.videoView.setOnTouchListener(touchListener) + + binding.progressBar.hide() + binding.videoView.useController = true + binding.videoView.showController() + } + else -> { /* do nothing */ } + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isAudio) return + if (isPlaying) { + hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS) + } else { + handler.removeCallbacks(hideToolbar) + } + } + + @SuppressLint("SyntheticAccessor") + override fun onPlayerError(error: PlaybackException) { + binding.progressBar.hide() + val message = getString( + R.string.error_media_playback, + error.cause?.message ?: error.message + ) + Snackbar.make(binding.root, message, Snackbar.LENGTH_INDEFINITE) + .setTextMaxLines(10) + .setAction(R.string.action_retry) { player?.prepare() } + .show() + } + } + + savedSeekPosition = savedInstanceState?.getLong(SEEK_POSITION) ?: 0 + + mediaAttachment = attachment + + finalizeViewSetup(url, attachment.previewUrl, attachment.description) + } + + override fun onStart() { + super.onStart() + if (Build.VERSION.SDK_INT > 23) { + initializePlayer() + binding.videoView.onResume() + } + } + override fun onResume() { super.onResume() - if (_binding != null) { + if (Build.VERSION.SDK_INT <= 23 || player == null) { + initializePlayer() if (mediaActivity.isToolbarVisible && !isAudio) { hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS) } - binding.videoView.start() + binding.videoView.onResume() + } + } + + private fun releasePlayer() { + player?.let { + savedSeekPosition = it.currentPosition + it.release() + player = null + binding.videoView.player = null } } override fun onPause() { super.onPause() - if (_binding != null) { + // If <= API 23 then multi-window mode is not available, so this is a good time to + // pause everything + if (Build.VERSION.SDK_INT <= 23) { + binding.videoView.onPause() + releasePlayer() handler.removeCallbacks(hideToolbar) - binding.videoView.pause() - mediaController.hide() + } + } + + override fun onStop() { + super.onStop() + + // If > API 23 then this might be multi-window, and definitely wasn't paused in onPause, + // so pause everything now. + if (Build.VERSION.SDK_INT > 23) { + binding.videoView.onPause() + releasePlayer() + handler.removeCallbacks(hideToolbar) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putLong(SEEK_POSITION, savedSeekPosition) + } + + private fun initializePlayer() { + ExoPlayer.Builder(requireContext()) + .setMediaSourceFactory(mediaSourceFactory) + .build().apply { + if (BuildConfig.DEBUG) addAnalyticsListener(EventLogger("$TAG:ExoPlayer")) + setMediaItem(MediaItem.fromUri(mediaAttachment.url)) + addListener(mediaPlayerListener) + repeatMode = Player.REPEAT_MODE_ONE + playWhenReady = true + seekTo(savedSeekPosition) + prepare() + player = this + } + + binding.videoView.player = player + + // 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 + } + + @SuppressLint("SyntheticAccessor") + override fun onLoadCleared(placeholder: Drawable?) { + view ?: return + binding.videoView.defaultArtwork = null + } + }) + } } } @@ -105,153 +359,20 @@ class ViewVideoFragment : ViewMediaFragment() { binding.mediaDescription.elevation = binding.videoView.elevation + 1 binding.videoView.transitionName = url - binding.videoView.setVideoPath(url) - mediaController = object : MediaController(mediaActivity) { - override fun show(timeout: Int) { - // We're doing manual auto-close management. - // Also, take focus back from the pause button so we can use the back button. - super.show(0) - mediaController.requestFocus() - } - override fun dispatchKeyEvent(event: KeyEvent?): Boolean { - if (event?.keyCode == KeyEvent.KEYCODE_BACK) { - if (event.action == KeyEvent.ACTION_UP) { - hide() - activity?.supportFinishAfterTransition() - } - return true - } - return super.dispatchKeyEvent(event) - } - } - - mediaController.setMediaPlayer(binding.videoView) - binding.videoView.setMediaController(mediaController) binding.videoView.requestFocus() - binding.videoView.setPlayPauseListener(object : ExposedPlayPauseVideoView.PlayPauseListener { - override fun onPlay() { - if (!isAudio) { - hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS) - } - } - - override fun onPause() { - if (!isAudio) { - handler.removeCallbacks(hideToolbar) - } - } - }) - binding.videoView.setOnPreparedListener { mp -> - val containerWidth = binding.videoContainer.measuredWidth.toFloat() - val containerHeight = binding.videoContainer.measuredHeight.toFloat() - val videoWidth = mp.videoWidth.toFloat() - val videoHeight = mp.videoHeight.toFloat() - - if (isAudio) { - binding.videoView.layoutParams.height = 1 - binding.videoView.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT - } else if (containerWidth / containerHeight > videoWidth / videoHeight) { - binding.videoView.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT - binding.videoView.layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT - } else { - binding.videoView.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT - binding.videoView.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT - } - - // Wait until the media is loaded before accepting taps as we don't want toolbar to - // be hidden until then. - binding.videoView.setOnTouchListener { _, _ -> - mediaActivity.onPhotoTap() - false - } - - // Audio doesn't cause the controller to show automatically - if (isAudio) { - mediaController.show() - } - - binding.progressBar.hide() - mp.isLooping = true - } if (requireArguments().getBoolean(ARG_START_POSTPONED_TRANSITION)) { mediaActivity.onBringUp() } } - private fun hideToolbarAfterDelay(delayMilliseconds: Long) { - handler.postDelayed(hideToolbar, delayMilliseconds) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - mediaActivity = activity as ViewMediaActivity - toolbar = mediaActivity.toolbar - _binding = FragmentViewVideoBinding.inflate(inflater, container, false) - return binding.root - } - - @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 - - val gestureDetector = GestureDetectorCompat( - requireContext(), - object : GestureDetector.SimpleOnGestureListener() { - override fun onDown(event: MotionEvent): Boolean { - return true - } - - override fun onFling( - e1: MotionEvent, - e2: MotionEvent, - velocityX: Float, - velocityY: Float - ): Boolean { - if (abs(velocityY) > abs(velocityX)) { - videoActionsListener.onDismiss() - return true - } - return false - } - } - ) - - var lastY = 0f - binding.root.setOnTouchListener { _, event -> - if (event.action == MotionEvent.ACTION_DOWN) { - lastY = event.rawY - } else if (event.pointerCount == 1 && event.action == MotionEvent.ACTION_MOVE) { - val diff = event.rawY - lastY - if (binding.videoView.translationY != 0f || abs(diff) > 40) { - binding.videoView.translationY += diff - val scale = (-abs(binding.videoView.translationY) / 720 + 1).coerceAtLeast(0.5f) - binding.videoView.scaleY = scale - binding.videoView.scaleX = scale - lastY = event.rawY - return@setOnTouchListener true - } - } else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { - if (abs(binding.videoView.translationY) > 180) { - videoActionsListener.onDismiss() - } else { - binding.videoView.animate().translationY(0f).scaleX(1f).scaleY(1f).start() - } - } - - gestureDetector.onTouchEvent(event) - } - - finalizeViewSetup(url, attachment.previewUrl, attachment.description) + private fun hideToolbarAfterDelay(delayMilliseconds: Int) { + handler.postDelayed(hideToolbar, delayMilliseconds.toLong()) } override fun onToolbarVisibilityChange(visible: Boolean) { - if (_binding == null || !userVisibleHint) { + if (!userVisibleHint) { return } @@ -265,27 +386,27 @@ class ViewVideoFragment : ViewMediaFragment() { binding.mediaDescription.animate().alpha(alpha) .setListener(object : AnimatorListenerAdapter() { + @SuppressLint("SyntheticAccessor") override fun onAnimationEnd(animation: Animator) { - if (_binding != null) { - binding.mediaDescription.visible(isDescriptionVisible) - } + view ?: return + binding.mediaDescription.visible(isDescriptionVisible) animation.removeListener(this) } }) .start() - if (visible && binding.videoView.isPlaying && !isAudio) { + if (visible && (binding.videoView.player?.isPlaying == true) && !isAudio) { hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS) } else { handler.removeCallbacks(hideToolbar) } } - override fun onTransitionEnd() { - } + override fun onTransitionEnd() { } - override fun onDestroyView() { - super.onDestroyView() - _binding = null + companion object { + private const val TAG = "ViewVideoFragment" + private const val TOOLBAR_HIDE_DELAY_MS = PlayerControlView.DEFAULT_SHOW_TIMEOUT_MS + private const val SEEK_POSITION = "seekPosition" } } 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 bfa961713..3976f5395 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -89,6 +89,11 @@ interface MastodonApi { @GET("api/v1/filters") suspend fun getFiltersV1(): NetworkResult> + @GET("api/v2/filters/{filterId}") + suspend fun getFilter( + @Path("filterId") filterId: String + ): NetworkResult + @GET("api/v2/filters") suspend fun getFilters(): NetworkResult> @@ -538,14 +543,16 @@ interface MastodonApi { @FormUrlEncoded @POST("api/v1/lists") suspend fun createList( - @Field("title") title: String + @Field("title") title: String, + @Field("exclusive") exclusive: Boolean? ): NetworkResult @FormUrlEncoded @PUT("api/v1/lists/{listId}") suspend fun updateList( @Path("listId") listId: String, - @Field("title") title: String + @Field("title") title: String, + @Field("exclusive") exclusive: Boolean? ): NetworkResult @DELETE("api/v1/lists/{listId}") 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 cf03115b3..3aaad1b70 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt @@ -379,9 +379,7 @@ class SendStatusService : Service(), Injectable { accountId: Long, statusId: Int ): Notification { - val intent = Intent(this, MainActivity::class.java) - intent.putExtra(NotificationHelper.ACCOUNT_ID, accountId) - intent.putExtra(MainActivity.OPEN_DRAFTS, true) + val intent = MainActivity.draftIntent(this, accountId) val pendingIntent = PendingIntent.getActivity( this, diff --git a/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt b/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt index 1e170da84..2bf761f9f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt @@ -19,6 +19,7 @@ import android.annotation.TargetApi import android.content.Intent import android.service.quicksettings.TileService import com.keylesspalace.tusky.MainActivity +import com.keylesspalace.tusky.components.compose.ComposeActivity /** * Small Addition that adds in a QuickSettings tile @@ -29,11 +30,8 @@ import com.keylesspalace.tusky.MainActivity class TuskyTileService : TileService() { override fun onClick() { - val intent = Intent(this, MainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - action = Intent.ACTION_SEND - type = "text/plain" - } + val intent = MainActivity.composeIntent(this, ComposeActivity.ComposeOptions()) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) startActivityAndCollapse(intent) } } 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 1a64f69b0..49041c1aa 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -41,7 +41,10 @@ enum class AppTheme(val value: String) { * * - Adding a new preference that does not change the interpretation of an existing preference */ -const val SCHEMA_VERSION = 2023022701 +const val SCHEMA_VERSION = 2023082301 + +/** The schema version for fresh installs */ +const val NEW_INSTALL_SCHEMA_VERSION = 0 object PrefKeys { // Note: not all of these keys are actually used as SharedPreferences keys but we must give @@ -61,7 +64,6 @@ object PrefKeys { const val ANIMATE_GIF_AVATARS = "animateGifAvatars" const val USE_BLURHASH = "useBlurhash" const val SHOW_SELF_USERNAME = "showSelfUsername" - const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter" const val SHOW_CARDS_IN_TIMELINES = "showCardsInTimelines" const val CONFIRM_REBLOGS = "confirmReblogs" const val CONFIRM_FAVOURITES = "confirmFavourites" @@ -104,4 +106,9 @@ 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" + } } 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 720dc817f..cb8b13e67 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt @@ -6,7 +6,6 @@ import androidx.activity.result.ActivityResultRegistryOwner import androidx.annotation.StringRes import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.LifecycleOwner -import androidx.preference.CheckBoxPreference import androidx.preference.EditTextPreference import androidx.preference.ListPreference import androidx.preference.Preference @@ -86,15 +85,6 @@ inline fun PreferenceParent.validatedEditTextPreference( return pref } -inline fun PreferenceParent.checkBoxPreference( - builder: CheckBoxPreference.() -> Unit -): CheckBoxPreference { - val pref = CheckBoxPreference(context) - builder(pref) - addPref(pref) - return pref -} - inline fun PreferenceParent.preferenceCategory( @StringRes title: Int? = null, builder: PreferenceParent.(PreferenceCategory) -> Unit diff --git a/app/src/main/java/com/keylesspalace/tusky/util/AlertDialogExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/AlertDialogExtensions.kt new file mode 100644 index 000000000..b79671a53 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/AlertDialogExtensions.kt @@ -0,0 +1,63 @@ +/* + * 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 android.content.DialogInterface +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine + +/** + * Wait for the alert dialog buttons to be clicked, return the ID of the clicked button + * + * @param positiveText Text to show on the positive button + * @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() } + } + + setButton(AlertDialog.BUTTON_POSITIVE, positiveText, listener) + negativeText?.let { setButton(AlertDialog.BUTTON_NEGATIVE, it, listener) } + neutralText?.let { setButton(AlertDialog.BUTTON_NEUTRAL, it, listener) } + + setOnCancelListener { cont.cancel() } + cont.invokeOnCancellation { dismiss() } + show() +} + +/** + * @see [AlertDialog.await] + */ +suspend fun AlertDialog.await( + @StringRes positiveTextResource: Int, + @StringRes negativeTextResource: Int? = null, + @StringRes neutralTextResource: Int? = null +) = await( + context.getString(positiveTextResource), + negativeTextResource?.let { context.getString(it) }, + neutralTextResource?.let { context.getString(it) } +) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CompositeWithOpaqueBackground.kt b/app/src/main/java/com/keylesspalace/tusky/util/CompositeWithOpaqueBackground.kt new file mode 100644 index 000000000..0aaef6d5c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/CompositeWithOpaqueBackground.kt @@ -0,0 +1,166 @@ +/* + * 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 android.graphics.Bitmap +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter +import android.graphics.Paint +import android.graphics.Shader +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.util.TypedValue +import android.view.View +import androidx.annotation.AttrRes +import androidx.core.content.ContextCompat +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool +import com.bumptech.glide.load.resource.bitmap.BitmapTransformation +import com.bumptech.glide.util.Util +import java.nio.ByteBuffer +import java.nio.charset.Charset +import java.security.MessageDigest + +/** + * Set an opaque background behind the non-transparent areas of a bitmap. + * + * Profile images may have areas that are partially transparent (i.e., alpha value >= 1 and < 255). + * + * Displaying those can be a problem if there is anything drawn under them, as it will show + * through the image. + * + * Fix this, by: + * + * - Creating a mask that matches the partially transparent areas of the image + * - Creating a new bitmap that, in the areas that match the mask, contains the same background + * drawable as the [ImageView]. + * - Composite the original image over the top + * + * So the partially transparent areas on the original image are composited over the original + * background, the fully transparent areas on the original image are left transparent. + */ +class CompositeWithOpaqueBackground(val view: View) : BitmapTransformation() { + override fun equals(other: Any?): Boolean { + if (other is CompositeWithOpaqueBackground) { + return other.view == view + } + return false + } + + override fun hashCode() = Util.hashCode(ID.hashCode(), view.hashCode()) + override fun updateDiskCacheKey(messageDigest: MessageDigest) { + messageDigest.update(ID_BYTES) + messageDigest.update(ByteBuffer.allocate(4).putInt(view.hashCode()).array()) + } + + override fun transform( + pool: BitmapPool, + toTransform: Bitmap, + outWidth: Int, + outHeight: Int + ): Bitmap { + // If the input bitmap has no alpha channel then there's nothing to do + if (!toTransform.hasAlpha()) return toTransform + + // Get the background drawable for this view, falling back to the given attribute + val backgroundDrawable = view.getFirstNonNullBackgroundOrAttr(android.R.attr.colorBackground) + backgroundDrawable ?: return toTransform + + // Convert the background to a bitmap. + val backgroundBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ARGB_8888) + when (backgroundDrawable) { + is ColorDrawable -> backgroundBitmap.eraseColor(backgroundDrawable.color) + else -> { + val backgroundCanvas = Canvas(backgroundBitmap) + backgroundDrawable.setBounds(0, 0, outWidth, outHeight) + backgroundDrawable.draw(backgroundCanvas) + } + } + + // Convert the alphaBitmap (where the alpha channel has 8bpp) to a mask of 1bpp + // TODO: toTransform.extractAlpha(paint, ...) could be used here, but I can't find any + // useful documentation covering paints and mask filters. + val maskBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ALPHA_8).apply { + val canvas = Canvas(this) + canvas.drawBitmap(toTransform, 0f, 0f, EXTRACT_MASK_PAINT) + } + + val shader = BitmapShader(backgroundBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT) + val paintShader = Paint() + paintShader.isAntiAlias = true + paintShader.shader = shader + paintShader.style = Paint.Style.FILL_AND_STROKE + + // Write the background to a new bitmap, masked to just the non-transparent areas of the + // original image + val dest = pool.get(outWidth, outHeight, toTransform.config) + val canvas = Canvas(dest) + canvas.drawBitmap(maskBitmap, 0f, 0f, paintShader) + + // Finally, write the original bitmap over the top + canvas.drawBitmap(toTransform, 0f, 0f, null) + + // Clean up intermediate bitmaps + pool.put(maskBitmap) + pool.put(backgroundBitmap) + + return dest + } + + companion object { + @Suppress("unused") + private const val TAG = "CompositeWithOpaqueBackground" + private val ID = CompositeWithOpaqueBackground::class.qualifiedName!! + private val ID_BYTES = ID.toByteArray(Charset.forName("UTF-8")) + + /** Paint with a color filter that converts 8bpp alpha images to a 1bpp mask */ + private val EXTRACT_MASK_PAINT = Paint().apply { + colorFilter = ColorMatrixColorFilter( + ColorMatrix( + floatArrayOf( + 0f, 0f, 0f, 0f, 0f, + 0f, 0f, 0f, 0f, 0f, + 0f, 0f, 0f, 0f, 0f, + 0f, 0f, 0f, 255f, 0f + ) + ) + ) + isAntiAlias = false + } + + /** + * @param attr attribute reference for the default drawable if no background is set on + * this view or any of its ancestors. + * @return The first non-null background drawable from this view, or its ancestors, + * falling back to the attribute resource given by `attr` if none of the views have a + * background. + */ + fun View.getFirstNonNullBackgroundOrAttr(@AttrRes attr: Int): Drawable? = + background ?: (parent as? View)?.getFirstNonNullBackgroundOrAttr(attr) ?: run { + val v = TypedValue() + context.theme.resolveAttribute(attr, v, true) + // TODO: On API 29 can use v.isColorType here + if (v.type >= TypedValue.TYPE_FIRST_COLOR_INT && v.type <= TypedValue.TYPE_LAST_COLOR_INT) { + ColorDrawable(v.data) + } else { + ContextCompat.getDrawable(context, v.resourceId) + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt index 7fcf77359..8de7129ce 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt @@ -20,7 +20,6 @@ package com.keylesspalace.tusky.util import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlin.time.Duration -import kotlin.time.ExperimentalTime import kotlin.time.TimeMark import kotlin.time.TimeSource @@ -54,7 +53,6 @@ import kotlin.time.TimeSource * @param timeout Emissions within this duration of the last emission are filtered * @param timeSource Used to measure elapsed time. Normally only overridden in tests */ -@OptIn(ExperimentalTime::class) fun Flow.throttleFirst( timeout: Duration, timeSource: TimeSource = TimeSource.Monotonic 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 1430801c2..0979f5964 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt @@ -3,39 +3,50 @@ 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 +import com.bumptech.glide.load.MultiTransformation +import com.bumptech.glide.load.Transformation import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.keylesspalace.tusky.R private val centerCropTransformation = CenterCrop() -fun loadAvatar(url: String?, imageView: ImageView, @Px radius: Int, animate: Boolean) { +fun loadAvatar( + url: String?, + imageView: ImageView, + @Px radius: Int, + animate: Boolean, + transforms: List>? = null +) { if (url.isNullOrBlank()) { Glide.with(imageView) .load(R.drawable.avatar_default) .into(imageView) } else { + val multiTransformation = MultiTransformation( + buildList { + transforms?.let { this.addAll(it) } + add(centerCropTransformation) + add(RoundedCorners(radius)) + } + ) + if (animate) { Glide.with(imageView) .load(url) - .transform( - centerCropTransformation, - RoundedCorners(radius) - ) + .transform(multiTransformation) .placeholder(R.drawable.avatar_default) .into(imageView) } else { Glide.with(imageView) .asBitmap() .load(url) - .transform( - centerCropTransformation, - RoundedCorners(radius) - ) + .transform(multiTransformation) .placeholder(R.drawable.avatar_default) .into(imageView) } 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 04176a11e..0bf7878df 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt @@ -68,7 +68,7 @@ fun setClickableText(view: TextView, content: CharSequence, mentions: List diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt index a3811a358..60633f725 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt @@ -30,9 +30,9 @@ fun Throwable.getServerErrorMessage(): String? { /** @return A drawable resource to accompany the error message for this throwable */ fun Throwable.getDrawableRes(): Int = when (this) { - is IOException -> R.drawable.elephant_offline - is HttpException -> R.drawable.elephant_offline - else -> R.drawable.elephant_error + is IOException -> R.drawable.errorphant_offline + is HttpException -> R.drawable.errorphant_offline + else -> R.drawable.errorphant_error } /** @return A string error message for this throwable */ 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 650d92ccb..c33533628 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt @@ -33,7 +33,7 @@ class BackgroundMessageView @JvmOverloads constructor( orientation = VERTICAL if (isInEditMode) { - setup(R.drawable.elephant_offline, R.string.error_network) {} + setup(R.drawable.errorphant_offline, R.string.error_network) {} } } @@ -61,6 +61,7 @@ class BackgroundMessageView @JvmOverloads constructor( binding.imageView.setImageResource(imageRes) binding.button.setOnClickListener(clickListener) binding.button.visible(clickListener != null) + binding.helpText.visible(false) } fun showHelp(@StringRes helpRes: Int) { diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt b/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt deleted file mode 100644 index 95605b18f..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.keylesspalace.tusky.view - -import android.content.Context -import android.util.AttributeSet -import android.widget.VideoView - -class ExposedPlayPauseVideoView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : - VideoView(context, attrs, defStyleAttr) { - - private var listener: PlayPauseListener? = null - private var playing = false - - fun setPlayPauseListener(listener: PlayPauseListener) { - this.listener = listener - } - - override fun start() { - super.start() - if (!playing) { - playing = true - listener?.onPlay() - } - } - - override fun pause() { - super.pause() - if (playing) { - playing = false - listener?.onPause() - } - } - - interface PlayPauseListener { - fun onPlay() - fun onPause() - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/SliderPreference.kt b/app/src/main/java/com/keylesspalace/tusky/view/SliderPreference.kt index 742a2cd76..12b0b990e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/SliderPreference.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/SliderPreference.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.res.TypedArray import android.graphics.drawable.Drawable import android.util.AttributeSet -import android.view.View.VISIBLE import androidx.appcompat.content.res.AppCompatResources import androidx.preference.Preference import androidx.preference.PreferenceViewHolder @@ -12,6 +11,8 @@ import com.google.android.material.slider.LabelFormatter.LABEL_GONE import com.google.android.material.slider.Slider import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.PrefSliderBinding +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show import java.lang.Float.max import java.lang.Float.min @@ -130,6 +131,8 @@ class SliderPreference @JvmOverloads constructor( binding.root.isClickable = false + binding.slider.clearOnChangeListeners() + binding.slider.clearOnSliderTouchListeners() binding.slider.addOnChangeListener(this) binding.slider.addOnSliderTouchListener(this) binding.slider.value = value // sliderValue @@ -141,24 +144,24 @@ class SliderPreference @JvmOverloads constructor( binding.slider.labelBehavior = LABEL_GONE binding.slider.isEnabled = isEnabled - binding.summary.visibility = VISIBLE + binding.summary.show() binding.summary.text = formatter(value) decrementIcon?.let { icon -> binding.decrement.icon = icon - binding.decrement.visibility = VISIBLE + binding.decrement.show() binding.decrement.setOnClickListener { value -= stepSize } - } + } ?: binding.decrement.hide() incrementIcon?.let { icon -> binding.increment.icon = icon - binding.increment.visibility = VISIBLE + binding.increment.show() binding.increment.setOnClickListener { value += stepSize } - } + } ?: binding.increment.hide() } override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { 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 ae24cebe1..998cc96a4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt @@ -35,7 +35,7 @@ data class AttachmentViewData( companion object { @JvmStatic - fun list(status: Status): List { + fun list(status: Status, alwaysShowSensitiveMedia: Boolean = false): List { val actionable = status.actionableStatus return actionable.attachments.map { attachment -> AttachmentViewData( @@ -43,7 +43,7 @@ data class AttachmentViewData( statusId = actionable.id, statusUrl = actionable.url!!, sensitive = actionable.sensitive, - isRevealed = !actionable.sensitive + isRevealed = alwaysShowSensitiveMedia || !actionable.sensitive ) } } 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 fe5110381..55de04be4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt @@ -35,7 +35,6 @@ 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 kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asFlow @@ -43,7 +42,6 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody -import okhttp3.RequestBody import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody import java.io.File @@ -52,6 +50,13 @@ import javax.inject.Inject private const val HEADER_FILE_NAME = "header.png" private const val AVATAR_FILE_NAME = "avatar.png" +internal data class ProfileDataInUi( + val displayName: String, + val note: String, + val locked: Boolean, + val fields: List +) + class EditProfileViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub, @@ -64,11 +69,10 @@ class EditProfileViewModel @Inject constructor( val headerData = MutableLiveData() val saveData = MutableLiveData>() - @OptIn(FlowPreview::class) val instanceData: Flow = instanceInfoRepo::getInstanceInfo.asFlow() .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) - private var oldProfileData: Account? = null + private var apiProfileAccount: Account? = null fun obtainProfile() = viewModelScope.launch { if (profileData.value == null || profileData.value is Error) { @@ -76,7 +80,7 @@ class EditProfileViewModel @Inject constructor( mastodonApi.accountVerifyCredentials().fold( { profile -> - oldProfileData = profile + apiProfileAccount = profile profileData.postValue(Success(profile)) }, { @@ -98,68 +102,49 @@ class EditProfileViewModel @Inject constructor( headerData.value = getHeaderUri() } - fun save(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List) { + internal fun save(newProfileData: ProfileDataInUi) { if (saveData.value is Loading || profileData.value !is Success) { return } saveData.value = Loading() - val displayName = if (oldProfileData?.displayName == newDisplayName) { - null - } else { - newDisplayName.toRequestBody(MultipartBody.FORM) - } - - val note = if (oldProfileData?.source?.note == newNote) { - null - } else { - newNote.toRequestBody(MultipartBody.FORM) - } - - val locked = if (oldProfileData?.locked == newLocked) { - null - } else { - newLocked.toString().toRequestBody(MultipartBody.FORM) - } - - val avatar = if (avatarData.value != null) { - val avatarBody = getCacheFileForName(AVATAR_FILE_NAME).asRequestBody("image/png".toMediaTypeOrNull()) - MultipartBody.Part.createFormData("avatar", randomAlphanumericString(12), avatarBody) - } else { - null - } - - val header = if (headerData.value != null) { - val headerBody = getCacheFileForName(HEADER_FILE_NAME).asRequestBody("image/png".toMediaTypeOrNull()) - MultipartBody.Part.createFormData("header", randomAlphanumericString(12), headerBody) - } else { - null - } - - // when one field changed, all have to be sent or they unchanged ones would get overridden - val fieldsUnchanged = oldProfileData?.source?.fields == newFields - val field1 = calculateFieldToUpdate(newFields.getOrNull(0), fieldsUnchanged) - val field2 = calculateFieldToUpdate(newFields.getOrNull(1), fieldsUnchanged) - val field3 = calculateFieldToUpdate(newFields.getOrNull(2), fieldsUnchanged) - val field4 = calculateFieldToUpdate(newFields.getOrNull(3), fieldsUnchanged) - - if (displayName == null && note == null && locked == null && avatar == null && header == null && - field1 == null && field2 == null && field3 == null && field4 == null - ) { - /** if nothing has changed, there is no need to make a network request */ - saveData.postValue(Success()) + val diff = getProfileDiff(apiProfileAccount, newProfileData) + if (!diff.hasChanges()) { + // if nothing has changed, there is no need to make an api call + saveData.value = Success() return } viewModelScope.launch { + var avatarFileBody: MultipartBody.Part? = null + diff.avatarFile?.let { + avatarFileBody = MultipartBody.Part.createFormData("avatar", randomAlphanumericString(12), it.asRequestBody("image/png".toMediaTypeOrNull())) + } + + var headerFileBody: MultipartBody.Part? = null + diff.headerFile?.let { + headerFileBody = MultipartBody.Part.createFormData("header", randomAlphanumericString(12), it.asRequestBody("image/png".toMediaTypeOrNull())) + } + mastodonApi.accountUpdateCredentials( - displayName, note, locked, avatar, header, - field1?.first, field1?.second, field2?.first, field2?.second, field3?.first, field3?.second, field4?.first, field4?.second + 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) ).fold( - { newProfileData -> + { newAccountData -> saveData.postValue(Success()) - eventHub.dispatch(ProfileEditedEvent(newProfileData)) + eventHub.dispatch(ProfileEditedEvent(newAccountData)) }, { throwable -> saveData.postValue(Error(errorMessage = throwable.getServerErrorMessage())) @@ -169,30 +154,95 @@ class EditProfileViewModel @Inject constructor( } // cache activity state for rotation change - fun updateProfile(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List) { + internal fun updateProfile(newProfileData: ProfileDataInUi) { if (profileData.value is Success) { - val newProfileSource = profileData.value?.data?.source?.copy(note = newNote, fields = newFields) + val newProfileSource = profileData.value?.data?.source?.copy(note = newProfileData.note, fields = newProfileData.fields) val newProfile = profileData.value?.data?.copy( - displayName = newDisplayName, - locked = newLocked, + displayName = newProfileData.displayName, + locked = newProfileData.locked, source = newProfileSource ) - profileData.postValue(Success(newProfile)) + profileData.value = Success(newProfile) } } - private fun calculateFieldToUpdate(newField: StringField?, fieldsUnchanged: Boolean): Pair? { + internal fun hasUnsavedChanges(newProfileData: ProfileDataInUi): Boolean { + val diff = getProfileDiff(apiProfileAccount, newProfileData) + + return diff.hasChanges() + } + + private fun getProfileDiff(oldProfileAccount: Account?, newProfileData: ProfileDataInUi): DiffProfileData { + val displayName = if (oldProfileAccount?.displayName == newProfileData.displayName) { + null + } else { + newProfileData.displayName + } + + val note = if (oldProfileAccount?.source?.note == newProfileData.note) { + null + } else { + newProfileData.note + } + + val locked = if (oldProfileAccount?.locked == newProfileData.locked) { + null + } else { + newProfileData.locked + } + + val avatarFile = if (avatarData.value != null) { + getCacheFileForName(AVATAR_FILE_NAME) + } else { + null + } + + val headerFile = if (headerData.value != null) { + getCacheFileForName(HEADER_FILE_NAME) + } else { + null + } + + // 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 } return Pair( - newField.name.toRequestBody(MultipartBody.FORM), - newField.value.toRequestBody(MultipartBody.FORM) + newField.name, + newField.value ) } private fun getCacheFileForName(filename: String): File { return File(application.cacheDir, filename) } + + private data class DiffProfileData( + val displayName: String?, + val note: String?, + val locked: Boolean?, + val field1: Pair?, + val field2: Pair?, + val field3: Pair?, + val field4: Pair?, + 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 + } } 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 f701847c6..e13328a90 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt @@ -38,7 +38,7 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi) } enum class Event { - CREATE_ERROR, DELETE_ERROR, RENAME_ERROR + CREATE_ERROR, DELETE_ERROR, UPDATE_ERROR } data class State(val lists: List, val loadingState: LoadingState) @@ -84,9 +84,9 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi) } } - fun createNewList(listName: String) { + fun createNewList(listName: String, exclusive: Boolean) { viewModelScope.launch { - api.createList(listName).fold( + api.createList(listName, exclusive).fold( { list -> updateState { copy(lists = lists + list) @@ -99,16 +99,16 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi) } } - fun renameList(listId: String, listName: String) { + fun updateList(listId: String, listName: String, exclusive: Boolean) { viewModelScope.launch { - api.updateList(listId, listName).fold( + api.updateList(listId, listName, exclusive).fold( { list -> updateState { copy(lists = lists.replacedFirstWhich(list) { it.id == listId }) } }, { - sendEvent(Event.RENAME_ERROR) + sendEvent(Event.UPDATE_ERROR) } ) } diff --git a/app/src/main/res/drawable/elephant_error.xml b/app/src/main/res/drawable/errorphant_error.xml similarity index 100% rename from app/src/main/res/drawable/elephant_error.xml rename to app/src/main/res/drawable/errorphant_error.xml diff --git a/app/src/main/res/drawable/elephant_offline.xml b/app/src/main/res/drawable/errorphant_offline.xml similarity index 100% rename from app/src/main/res/drawable/elephant_offline.xml rename to app/src/main/res/drawable/errorphant_offline.xml diff --git a/app/src/main/res/drawable/ic_content_copy_24.xml b/app/src/main/res/drawable/ic_content_copy_24.xml new file mode 100644 index 000000000..bac0f6001 --- /dev/null +++ b/app/src/main/res/drawable/ic_content_copy_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout-sw640dp/fragment_timeline.xml b/app/src/main/res/layout-sw640dp/fragment_timeline.xml index 6d014e702..af49280d9 100644 --- a/app/src/main/res/layout-sw640dp/fragment_timeline.xml +++ b/app/src/main/res/layout-sw640dp/fragment_timeline.xml @@ -46,7 +46,7 @@ app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" - tools:src="@drawable/elephant_error" + tools:src="@drawable/errorphant_error" tools:visibility="visible" /> diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml index 91919bc6b..0b46cda4c 100644 --- a/app/src/main/res/layout/activity_about.xml +++ b/app/src/main/res/layout/activity_about.xml @@ -21,104 +21,190 @@ android:layout_gravity="center" android:textDirection="anyRtl"> - + + + + + android:textAppearance="@style/TextAppearance.AppCompat.Medium" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/versionTextView" + tools:text="Your device" /> + + + + + + + + - + android:textAppearance="@style/TextAppearance.AppCompat.Subhead" + app:layout_constraintEnd_toEndOf="@+id/copyDeviceInfo" + app:layout_constraintStart_toStartOf="@+id/deviceInfo" + app:layout_constraintTop_toBottomOf="@+id/accountInfo" /> + app:layout_constraintEnd_toEndOf="@+id/aboutWebsiteInfoTextView" + app:layout_constraintStart_toStartOf="@+id/aboutWebsiteInfoTextView" + app:layout_constraintTop_toBottomOf="@id/aboutWebsiteInfoTextView" />