Compare commits
318 Commits
1.0.beta19
...
master
Author | SHA1 | Date |
---|---|---|
Matthieu | 187c29a751 | |
Balaraz | f9a07a5dd0 | |
Alexandre NICOLADIE | fb9296187a | |
Matthieu | 04324577ea | |
Matthieu | 73f08e5a5f | |
Matthieu | a7feab380b | |
Matthieu | 1a4e023091 | |
Regu_Miabyss | 2fb4c91ffd | |
Matthieu | 720c099f0a | |
Matthieu | 869479d53f | |
Matthieu | 018f893388 | |
Matthieu | f27ae611be | |
Matthieu | 234be72f59 | |
Matthieu | 7eaac2e903 | |
Matthieu | a9849c13e6 | |
Matthieu | d1562f18e9 | |
Matthieu | 4a1248bcab | |
Regu_Miabyss | e9122e6d72 | |
Matthieu | 76eac62d73 | |
Matthieu | dd27555d83 | |
Matthieu | c0feb8a37d | |
Matthieu | 7acd4cface | |
Matthieu | 10e93c90b7 | |
Matthieu | 2311627473 | |
Regu_Miabyss | 6c7ab2333e | |
Matthieu | 54711d4e81 | |
Matthieu | 908d1a54c9 | |
Matthieu | 2b91543137 | |
Matthieu | 0688dd4d02 | |
Matthieu | 319da7c11c | |
Matthieu | 7b327fc0d6 | |
Matthieu | 64ab2c2ac5 | |
Matthieu | 37b83f5ae2 | |
Matthieu | 1516452ab5 | |
Matthieu | cb50db7730 | |
Matthieu | afe6f71152 | |
Matthieu | d66c365934 | |
Matthieu | 7815ecba08 | |
Matthieu | b4533014b3 | |
Matthieu | 905c1c2d66 | |
Matthieu | 46ee92a19f | |
Matthieu | ed7ff877fb | |
Francesco Marinucci | 5c3b231e16 | |
Matthieu | 80e021e1a2 | |
Matthieu | 0aa3d86c11 | |
Matthieu | 05cb615f15 | |
Matthieu | 9313f321cd | |
Matthieu | 175438115d | |
Matthieu | f2600b85e2 | |
Matthieu | 711a5b310f | |
Francesco Marinucci | 0192190c6d | |
Matthieu | bb935e73ad | |
Matthieu | ddf1b273de | |
Matthieu | 14dee5463e | |
Matthieu | 416d36b1a8 | |
Matthieu | 370aeda4a6 | |
Matthieu | eb65e24099 | |
Fred | 1ea4371b3e | |
Matthieu | 2e399884e9 | |
Matthieu | 1dcf605976 | |
Fred | 06478cf8a7 | |
Fred | 580f7ca911 | |
Matthieu | 23fbebfe44 | |
Fred | 6602d912a9 | |
Matthieu | 1f41268d55 | |
Francesco Marinucci | 15efdcabad | |
Matthieu | 97069a76db | |
Francesco Marinucci | 834b3f86bd | |
ButterflyOfFire | adc4ef0199 | |
Matthieu | dafe827a5c | |
Matthieu | 0f8602b3f1 | |
Matthieu | 6edb394ccb | |
Weblate | 13fa45cdb0 | |
Matthieu | e108d08916 | |
Matthieu | 27413cd08e | |
Matthieu | b975a255f9 | |
Matthieu | 49ec982464 | |
Matthieu | 9b1573dd8b | |
Matthieu | c91b8a391a | |
Matthieu | 7dea955261 | |
Matthieu | 69a0c13d32 | |
Matthieu | 5149150f27 | |
Matthieu | 2d988705d5 | |
Matthieu | d9458f50e3 | |
Matthieu | 54eb7f20ef | |
Matthieu | f3a870d83e | |
Matthieu | 89bf93c0e5 | |
Matthieu | 77996d26ba | |
Matthieu | f61bf5b1df | |
Matthieu | 860a639d42 | |
Matthieu | 1e6c3a9d5a | |
Matthieu | 8703287d90 | |
Matthieu | bb3c9afb13 | |
Matthieu | 0290e6f8d5 | |
Matthieu | 4008d2a2fc | |
Matthieu | 3fdb7762b6 | |
Matthieu | f2c1ae3942 | |
Matthieu | 4ac7aa6bcb | |
Matthieu | 59e29ef232 | |
Matthieu | f16f1a9927 | |
Matthieu | 6c85115b67 | |
Matthieu | f0face3fb0 | |
Matthieu | c40c6f70dc | |
Matthieu | 49d76ff866 | |
Matthieu | 64ffaf081e | |
Matthieu | a5334fb637 | |
Matthieu | 70e49ee60c | |
Matthieu | fc43bc2ff4 | |
Matthieu | d393b083ae | |
Matthieu | 03e2ab8d49 | |
Matthieu | 795b54c3cd | |
Matthieu | 8268e9e8b5 | |
Matthieu | eb014290ff | |
Matthieu | db3da57b7b | |
Matthieu | ae54b83ec7 | |
Matthieu | dda06b1cd5 | |
Matthieu | 73193abd95 | |
Matthieu | f60889ea14 | |
Matthieu | 888c6328d9 | |
Matthieu | 6876b1c449 | |
mittwerk | a4a2505adb | |
Weblate Admin | d48e63c65e | |
Matthieu | 1956469b7d | |
Alex Camilleri | 4817188a5b | |
Matthieu | 2d61ff855a | |
Grzegorz Cichocki | aad904dd07 | |
Fred | f15c23cceb | |
Fred | d9da842df7 | |
Matthieu | 88cd015325 | |
Petri Salmela | 764d610898 | |
Matthieu | d8adbbfae4 | |
Mostafa Ahangarha | 0d449551d7 | |
Matthieu | 8f7cedb508 | |
Matthieu | 21c68fdfae | |
Matthieu | 55a0aaa394 | |
Matthieu | 99a9470ff9 | |
Matthieu | cfa254c4f1 | |
Matthieu | e0e2ef632b | |
Matthieu | 7e18dec6e8 | |
Matthieu | aca93b63fb | |
Matthieu | b2c88d4da3 | |
Xose M | eb544c9f23 | |
Matthieu | 0d45804b0f | |
Niko Diamadis | b4f0ea29c0 | |
Matthieu | 251110d71f | |
Cyb3rKo | 4a46064c45 | |
Matthieu | ba7becf5f3 | |
Matthieu | d03314f734 | |
Matthieu | 26e7588a75 | |
Adam Williams | 43c4520316 | |
Matthieu | b9df1e1a89 | |
Mostafa Ahangarha | e789e638de | |
Matthieu | b57565b25b | |
Кірыл Жаркоў | abf0d5eee4 | |
Matthieu | 23e22a6356 | |
Felipe Nogueira | 52f271b7f6 | |
Matthieu | 43d3947891 | |
Felipe Nogueira | bd1f0773bc | |
Matthieu | ada2f41010 | |
Xose M | e13638b5b6 | |
Matthieu | f8fdba20ba | |
Xose M | d85ecf8222 | |
Matthieu | d0247aab3f | |
Felipe Nogueira | 68fcf9bdf7 | |
Matthieu | 136d50d4c4 | |
Matthieu | aca37c2704 | |
Matthieu | 3fb683006a | |
Felipe Nogueira | dfefd6be44 | |
Matthieu | 37e3387189 | |
Matthieu | ad9edc5689 | |
Matthieu | c1eac85385 | |
Matthieu | 3350b54168 | |
Matthieu | 9abebb209a | |
Felipe Nogueira | 1f379dd3f7 | |
Matthieu | 428c4507c8 | |
Felipe Nogueira | 3f097e19bc | |
Matthieu | 3814ae1be9 | |
Felipe Nogueira | 212596947b | |
Matthieu | 55c8399f1f | |
Felipe Nogueira | 467ea4e85e | |
Matthieu | a1c18b8c27 | |
Aitor Salaberria | cec4aa0a58 | |
Matthieu | 56e3ac442a | |
Aitor Salaberria | f4d6773d12 | |
Matthieu | e87826efca | |
Aitor Salaberria | a4cb016d7c | |
Matthieu | 9df07abacb | |
Aitor Salaberria | 5f7d7b5d3b | |
Matthieu | a777fe4337 | |
Matthieu | d8bfb99243 | |
Matthieu | 7f1c9b8b16 | |
Matthieu | f87e7dbcc4 | |
Matthieu | 20e86782d9 | |
Andrewblasco | 8a5f14e594 | |
Matthieu | 42f8b4b0c0 | |
Matthieu | cdf024b339 | |
Matthieu | 233167d227 | |
Matthieu | 9ebf11b8df | |
Matthieu | 4ed9c385d6 | |
Matthieu | 29f09fc0dd | |
Matthieu | e27224725d | |
Matthieu | 04244bacff | |
Matthieu | 713571e4dc | |
Matthieu | 3d0ec86ac7 | |
fgerber | 09f272eb3c | |
fgerber | 1aa9f7bca2 | |
fgerber | 3c40978d50 | |
Matthieu | 16ef6d0cbd | |
Tomas | 513e6e5197 | |
Matthieu | 5d80c564e9 | |
Tomas | f62dd8cc39 | |
Matthieu | e3c0be4dbf | |
Weblate Admin | 4857d0fcb7 | |
Matthieu | a7877e3a26 | |
Danial Behzadi | 80401169a1 | |
Matthieu | 4c90591f30 | |
Murat H | 87e34a39ec | |
Matthieu | 63d242b12f | |
Weblate Admin | 47928aa26f | |
Matthieu | e33dd2dff2 | |
ButterflyOfFire | 93a4c9db67 | |
Matthieu | f515ed49e9 | |
Danial Behzadi | 4ff2ec3c1b | |
Matthieu | f288474dce | |
Matthieu | 3d5851a664 | |
Matthieu | ff03e11976 | |
Matthieu | 75ae26fa47 | |
Matthieu | 5b224976cc | |
Matthieu | c913adeda6 | |
Matthieu | 50ddbc40ed | |
Matthieu | 5dd5057479 | |
Matthieu | a28be0edf4 | |
Xose M | 41c72b4650 | |
Matthieu | fb47217e57 | |
Murat H | 8b68cb6811 | |
MagT | b5478fb0f7 | |
Matthieu | cbd947fa89 | |
Matthieu | 09cec0d36a | |
Matthieu | fab2f99581 | |
Matthieu | be7113aa25 | |
Matthieu | 3d4e32cf4b | |
Matthieu | e64c2d6399 | |
Matthieu | 1d6b3c47e7 | |
Matthieu | 366410e31c | |
Matthieu | 67e92a8dfa | |
Matthieu | 80d205fd74 | |
Matthieu | 21add3df46 | |
Matthieu | b0bd6d670d | |
Matthieu | d07d35a3dd | |
Murat H | 446ad44f4b | |
Weblate Admin | 5abde6608e | |
Matthieu | 4e93c6ec0c | |
Murat H | 244452a577 | |
Matthieu | 552b85c59d | |
Matthieu | e539ce9232 | |
fgerber | ca09fba7f3 | |
fgerber | aa5c86d711 | |
fgerber | 4f3020e0be | |
Matthieu | 80d8e40be9 | |
Matthieu | 651832d35e | |
Matthieu | 4a2234dc78 | |
MagT | 2fe334d32e | |
Matthieu | d5f1358397 | |
Matthieu | a1ea61a7bd | |
fgerber | ba26871572 | |
fgerber | c879486153 | |
fgerber | d60bce4b72 | |
fgerber | 553c65f7bc | |
fgerber | 8325067566 | |
fgerber | 2e558017f2 | |
fgerber | 79c2f2a391 | |
fgerber | 20d38d3fa8 | |
fgerber | 1c8a7d8b7d | |
Matthieu | 8237cd03fd | |
Matthieu | e6c1ef766d | |
Matthieu | d78ef56489 | |
Matthieu | c6d43797cc | |
Matthieu | 1481cdc909 | |
Matthieu | c518baa33d | |
fgerber | c726bbb448 | |
fgerber | e5ff66a83e | |
fgerber | 8ed13cdf2e | |
fgerber | e40a774b1f | |
Matthieu | 6572f0018a | |
Matthieu | 147046b7b4 | |
Matthieu | 1fa4b80fe7 | |
Matthieu | c21e277485 | |
Matthieu | d2c9c1fd47 | |
Matthieu | 35a609613b | |
Matthieu | e2ee562841 | |
Matthieu | 214ba98bc4 | |
Matthieu | b36fadd76c | |
Matthieu | 5644a22d38 | |
Marie | 851d95bf0f | |
Matthieu | 2d712ed395 | |
Matthieu | 2497504530 | |
Matthieu | e35cb17879 | |
Matthieu | 5b3870b80d | |
Matthieu | 085a1f548c | |
Matthieu | 2ca9a9b896 | |
Matthieu | 06ceb12f23 | |
Marie | 849ce3a565 | |
Matthieu | 442e07da59 | |
Marie Jaillot | cb9180fb60 | |
Matthieu | 269276f23d | |
Matthieu | 888b6aecc3 | |
Matthieu | 2ecc31e278 | |
Matthieu | 4ccf6deb9c | |
Matthieu | b8c6500022 | |
Matthieu | 665a1add07 | |
Matthieu | bb543c3217 | |
Matthieu | 963dcad8e4 | |
Matthieu | 6b42677f1e | |
Matthieu | 9bcf587f49 | |
Matthieu | 069c11478a | |
fgerber | 91819dd4db | |
fgerber | 8b625b43ad | |
Frédéric Gerber | 746242eed8 |
117
.gitlab-ci.yml
117
.gitlab-ci.yml
|
@ -1,21 +1,48 @@
|
|||
image: registry.gitlab.com/fdroid/ci-images-client
|
||||
image: registry.gitlab.com/fdroid/fdroidserver:buildserver-bullseye
|
||||
|
||||
variables:
|
||||
GIT_SUBMODULE_STRATEGY: recursive
|
||||
GIT_SUBMODULE_FORCE_HTTPS: "true"
|
||||
|
||||
before_script:
|
||||
- export GRADLE_USER_HOME=`pwd`/.gradle
|
||||
|
||||
.base:
|
||||
before_script:
|
||||
- test -e /etc/apt/sources.list.d/bullseye-backports.list
|
||||
|| echo "deb http://deb.debian.org/debian bullseye-backports main" >> /etc/apt/sources.list
|
||||
- apt update
|
||||
- apt-get -qy install -t bullseye-backports --no-install-recommends git sdkmanager
|
||||
|
||||
- test -n "$ANDROID_HOME" || source /etc/profile.d/bsenv.sh
|
||||
|
||||
- export cmdline_tools_latest="$ANDROID_HOME/cmdline-tools/latest/bin"
|
||||
- test -e $cmdline_tools_latest && export PATH="$cmdline_tools_latest:$PATH"
|
||||
|
||||
- export GRADLE_USER_HOME=$PWD/.gradle
|
||||
- export ANDROID_COMPILE_SDK=`sed -n 's,.*compileSdk\s*\([0-9][0-9]*\).*,\1,p' app/build.gradle`
|
||||
- echo y | sdkmanager "platforms;android-${ANDROID_COMPILE_SDK}" > /dev/null
|
||||
|
||||
- apt-get update || apt-get update
|
||||
- apt-get install -y openjdk-17-jdk-headless
|
||||
- update-java-alternatives -s java-1.17.0-openjdk-amd64
|
||||
after_script:
|
||||
# this file changes every time but should not be cached
|
||||
- rm -f $GRADLE_USER_HOME/caches/modules-2/modules-2.lock
|
||||
- rm -fr $GRADLE_USER_HOME/caches/*/plugin-resolution/
|
||||
cache:
|
||||
paths:
|
||||
- .gradle/wrapper
|
||||
- .gradle/caches
|
||||
|
||||
|
||||
# Basic android and gradle stuff
|
||||
# Check linting
|
||||
lintDebug:
|
||||
extends: .base
|
||||
interruptible: true
|
||||
stage: build
|
||||
script:
|
||||
- apt-get update || apt-get update
|
||||
- apt-get install -y openjdk-11-jdk-headless
|
||||
- update-alternatives --auto java
|
||||
- ./gradlew checkLicenses
|
||||
- ./gradlew -Pci --console=plain :app:lintDebug -PbuildDir=lint --write-verification-metadata sha256
|
||||
- git diff --quiet gradle/verification-metadata.xml || (echo 'Verification of dependencies failed!' && exit 1)
|
||||
artifacts:
|
||||
|
@ -26,14 +53,12 @@ lintDebug:
|
|||
|
||||
# Make Project
|
||||
assembleDebug:
|
||||
extends: .base
|
||||
interruptible: true
|
||||
stage: build
|
||||
tags:
|
||||
- server_artectrex
|
||||
script:
|
||||
- apt-get update || apt-get update
|
||||
- apt-get install -y openjdk-11-jdk-headless
|
||||
- update-alternatives --auto java
|
||||
- ./gradlew assembleDebug
|
||||
artifacts:
|
||||
paths:
|
||||
|
@ -41,27 +66,37 @@ assembleDebug:
|
|||
|
||||
# Run all tests, if any fails, interrupt the pipeline (fail it)
|
||||
debugTests:
|
||||
extends: .base
|
||||
interruptible: true
|
||||
stage: test
|
||||
script:
|
||||
- apt-get update || apt-get update
|
||||
- apt-get install -y openjdk-11-jdk-headless
|
||||
- update-alternatives --auto java
|
||||
- ./gradlew -Pci --console=plain :app:testDebug -x lint
|
||||
|
||||
.connected-template: &connected-template
|
||||
extends: .base
|
||||
interruptible: true
|
||||
allow_failure: true
|
||||
image: briar/ci-image-android-emulator:latest
|
||||
stage: test
|
||||
script:
|
||||
- start-emulator
|
||||
- wait-for-emulator
|
||||
- adb devices
|
||||
- adb shell input keyevent 82 &
|
||||
# Switch to right java version for building the app
|
||||
- apt-get update || apt-get update
|
||||
- apt-get install -y openjdk-11-jdk-headless
|
||||
- update-alternatives --auto java
|
||||
- ./gradlew connectedStagingAndroidTest --info || (adb -e logcat -d > logcat.txt; exit 1)
|
||||
- export JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64"
|
||||
- ./gradlew assembleStaging
|
||||
- export AVD_SDK=`echo $CI_JOB_NAME | awk '{print $2}'`
|
||||
- export AVD_TAG=`echo $CI_JOB_NAME | awk '{print $3}'`
|
||||
- export AVD_ARCH=`echo $CI_JOB_NAME | awk '{print $4}'`
|
||||
- export AVD_PACKAGE="system-images;android-${AVD_SDK};${AVD_TAG};${AVD_ARCH}"
|
||||
- echo $AVD_PACKAGE
|
||||
- $ANDROID_HOME/cmdline-tools/latest/bin/avdmanager --verbose delete avd --name "$NAME_AVD"
|
||||
- export AVD="$AVD_PACKAGE"
|
||||
- echo y | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install "$AVD"
|
||||
- echo no | $ANDROID_HOME/cmdline-tools/latest/bin/avdmanager --verbose create avd --name "$NAME_AVD" --package "$AVD" --device "pixel"
|
||||
- start-emulator.sh
|
||||
- ./gradlew installStaging
|
||||
- adb shell am start -n org.pixeldroid.app.debug/org.pixeldroid.app.MainActivity
|
||||
- if [ $AVD_SDK -lt 25 ] || ! emulator -accel-check; then
|
||||
export FLAG=-Pandroid.testInstrumentationRunnerArguments.notAnnotation=androidx.test.filters.LargeTest;
|
||||
fi
|
||||
- ./gradlew connectedStagingAndroidTest $FLAG --info || (adb -e logcat -d > logcat.txt; exit 1)
|
||||
artifacts:
|
||||
paths:
|
||||
- logcat.txt
|
||||
|
@ -69,12 +104,16 @@ debugTests:
|
|||
connected 27 default x86_64:
|
||||
<<: *connected-template
|
||||
|
||||
|
||||
#inspired from https://gitlab.com/mvglasow/satstat/-/blob/master/.gitlab-ci.yml
|
||||
fdroid build:
|
||||
stage: build
|
||||
image: registry.gitlab.com/fdroid/ci-images-client:latest
|
||||
image: registry.gitlab.com/mvglasow/ci-images-server:latest
|
||||
tags:
|
||||
- server_artectrex
|
||||
allow_failure: true
|
||||
artifacts:
|
||||
# name: "${CI_PROJECT_PATH}_${CI_JOB_STAGE}_${CI_COMMIT_REF_NAME}_${CI_COMMIT_SHA}"
|
||||
paths:
|
||||
- signed/
|
||||
when: always
|
||||
|
@ -83,43 +122,13 @@ fdroid build:
|
|||
cache:
|
||||
key: "$CI_JOB_NAME"
|
||||
paths:
|
||||
- .gradle
|
||||
- .android
|
||||
- .gradle
|
||||
script:
|
||||
# Put the correct versionName and versionCode in the .fdroid.yml
|
||||
# Put the correct versionName and versionCode in the .fdroid.yml
|
||||
- sed -e "s/\${versionName}/$(grep "versionName " app/build.gradle | awk '{print $2}' | tr -d \")$(grep "versionCode" app/build.gradle -m 1 | awk '{print $2}')/" -e "s/\${versionCode}/$(grep "versionCode" app/build.gradle -m 1 | awk '{print $2}')/" .fdroid.yml.template > .fdroid.yml
|
||||
- rm .fdroid.yml.template
|
||||
- test -d build || mkdir build
|
||||
- test -d fdroidserver || mkdir fdroidserver
|
||||
- git ls-remote https://gitlab.com/fdroid/fdroidserver.git master
|
||||
- curl --silent https://gitlab.com/fdroid/fdroidserver/-/archive/master/fdroidserver-master.tar.gz
|
||||
| tar -xz --directory=fdroidserver --strip-components=1
|
||||
- export PATH="`pwd`/fdroidserver:$PATH"
|
||||
- export PYTHONPATH="$CI_PROJECT_DIR/fdroidserver:$CI_PROJECT_DIR/fdroidserver/examples"
|
||||
- export PYTHONUNBUFFERED=true
|
||||
|
||||
- bash fdroidserver/buildserver/setup-env-vars $ANDROID_HOME
|
||||
- adduser --disabled-password --gecos "" vagrant
|
||||
- ln -s $CI_PROJECT_DIR/fdroidserver /home/vagrant/fdroidserver
|
||||
- mkdir -p /vagrant/cache
|
||||
- wget -q https://services.gradle.org/distributions/gradle-5.6.2-bin.zip --output-document=/vagrant/cache/gradle-5.6.2-bin.zip
|
||||
# Check sha256 of the gradle build
|
||||
- echo '32fce6628848f799b0ad3205ae8db67d0d828c10ffe62b748a7c0d9f4a5d9ee0 /vagrant/cache/gradle-5.6.2-bin.zip' | sha256sum -c
|
||||
- bash fdroidserver/buildserver/provision-gradle
|
||||
- bash fdroidserver/buildserver/provision-apt-get-install https://deb.debian.org/debian
|
||||
- source /etc/profile.d/bsenv.sh
|
||||
- apt-get dist-upgrade
|
||||
|
||||
# install fdroidserver from git, with deps from Debian, until fdroidserver
|
||||
# is stable enough to include all the things needed here
|
||||
- apt-get install -t stretch-backports
|
||||
fdroidserver
|
||||
python3-asn1crypto
|
||||
python3-ruamel.yaml
|
||||
yamllint
|
||||
- apt-get purge fdroidserver
|
||||
- export GRADLE_USER_HOME=$PWD/.gradle
|
||||
# each fdroid build --on-server run expects sudo, then uninstalls it
|
||||
# each `fdroid build --on-server` run expects sudo, then uninstalls it
|
||||
- set -x
|
||||
- apt-get install sudo
|
||||
- fdroid fetchsrclibs --verbose
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
[submodule "scrambler"]
|
||||
path = scrambler
|
||||
url = https://gitlab.com/artectrex/scrambler.git
|
||||
[submodule "pixel_common"]
|
||||
path = pixel_common
|
||||
url = https://gitlab.shinice.net/pixeldroid/pixel_common.git
|
||||
|
|
|
@ -16,4 +16,56 @@ If you encounter a problem or have an idea about how to make PixelDroid better,
|
|||
|
||||
You can also help us solve one of the existing issues, or improve the application in some other way, by contributing changes yourself. To do this you can fork the project and submit a Merge Request.
|
||||
|
||||
Before starting to work on an issue or an improvement, you can ask us on our Matrix channel (#pixeldroid:gnugen.ch) what we think, or make a comment on the relevant issue, so that we might point you in the right direction, and to make sure someone else is not already working on it.
|
||||
Before starting to work on an issue or an improvement, you can ask us on our Matrix channel (#pixeldroid:gnugen.ch) what we think, or make a comment on the relevant issue, so that we might point you in the right direction, and to make sure someone else is not already working on it.
|
||||
|
||||
## How to get started
|
||||
|
||||
Download the latest version of [Android Studio](https://developer.android.com/studio)
|
||||
|
||||
Then clone the project: you need to clone it with submodules: `git clone --recurse-submodules`.
|
||||
You can find more information about submodules [here](https://git-scm.com/book/en/v2/Git-Tools-Submodules)
|
||||
|
||||
When first opening the project, you might encounter this issue:
|
||||
|
||||
![Gradle issue](Screenshots/gradle-issue.png)
|
||||
|
||||
F-Droid requires ``distributionSha256Sum``. You are given three links to solve this issue, choose the first one:
|
||||
|
||||
![Click on this link](Screenshots/gradle-solution.png)
|
||||
|
||||
### Changing Gradle dependencies
|
||||
|
||||
Every time you change any dependency in ``build.gradle``, you will encounter the following error:
|
||||
|
||||
![Gradle dependency](Screenshots/gradle-dependency-verification.png)
|
||||
|
||||
This is because PixelDroid has [dependency verification](https://docs.gradle.org/current/userguide/dependency_verification.html) enabled.
|
||||
Dependency verification is useful to protect against supply chain attacks.
|
||||
Only dependencies which are in the `verification-metadata.xml` file are allowed to be used.
|
||||
|
||||
However, this means that whenever the dependencies change (for example updates or new dependencies),
|
||||
we have to update the `verification-metadata.xml` file. To avoid doing this manually, you can follow
|
||||
the following steps:
|
||||
|
||||
In the top toolbar, go to ``Edit Configurations... > Gradle`` and click on ``+`` in the top left corner to add a new run configuration.
|
||||
In the field ``Run``, write the command that triggered the dependency error,
|
||||
which in your case is probably ``assembleDebug`` (command executed when pressing the play button) and give it the arguments ``--write-verification-metadata sha256``.
|
||||
|
||||
![Run Configuration](Screenshots/run-configuration.png)
|
||||
|
||||
You can now build PixelDroid this new configuration: select it and then press the play button.
|
||||
|
||||
![Run with new config](Screenshots/run-new-config.png)
|
||||
|
||||
This has to be done only once when you encounter this error, then you can run the app as usual again,
|
||||
by selecting the "app" Run Configuration.
|
||||
|
||||
`--write-verification-metadata` has now added the new dependencies to the `verification-metadata.xml` file.
|
||||
When you do a Merge Request, we will check that the added values make sense.
|
||||
|
||||
### Getting your changes into the app
|
||||
|
||||
Once you are done with your changes, you should create a Merge Request.
|
||||
Depending on if you were given write access to the repository, you may have to create a fork and submit a merge request from there.
|
||||
Consult the [GitLab documentation](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) for more details,
|
||||
and don't hesitate to ask on our Matrix channel (#pixeldroid:gnugen.ch) if you get stuck :)
|
|
@ -9,13 +9,16 @@ Free (as in freedom) Android client for Pixelfed, the federated image sharing pl
|
|||
<img src="https://pixeldroid.org/badge-fdroid.png" alt="Get it on F-Droid" width="206">
|
||||
</a>
|
||||
|
||||
Come talk to us on Matrix, at <a href="https://matrix.to/#/#pixeldroid:gnugen.ch">#pixeldroid:gnugen.ch</a> !
|
||||
|
||||
## 🔧 Compiling the code yourself
|
||||
If you want to try out PixelDroid on your own device, you can compile the source code yourself. To do that you can install [Android Studio](https://developer.android.com/studio/).
|
||||
|
||||
## 🎨 Art attribution
|
||||
Our mascot was commissioned using funds from NLnet. The original file is `pixeldroid_mascot.svg` and it is adapted to work as an Android Drawable. This work is licensed under a [Creative Commons Attribution-ShareAlike 4.0 International License](http://creativecommons.org/licenses/by-sa/4.0/) (CC BY-SA).
|
||||
|
||||
Various works have been used from the pixelfed branding repository ( https://github.com/pixelfed/brand-assets ). In addition, a drawing of a red panda is used for some error messages ( https://thenounproject.com/search/?q=red+panda&i=2877785 ).
|
||||
Various works have been used from the pixelfed branding repository ( https://github.com/pixelfed/brand-assets ).
|
||||
|
||||
## 🤝 Contribute
|
||||
If you want to contribute, you can check out [CONTRIBUTING.md](CONTRIBUTING.md) and/or [TRANSLATION.md](TRANSLATION.md)
|
||||
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
After Width: | Height: | Size: 57 KiB |
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
210
app/build.gradle
210
app/build.gradle
|
@ -1,38 +1,56 @@
|
|||
import com.android.build.api.dsl.ManagedVirtualDevice
|
||||
|
||||
plugins {
|
||||
id "com.cookpad.android.plugin.license-tools" version "1.2.8"
|
||||
id("com.android.application")
|
||||
id("com.google.dagger.hilt.android")
|
||||
id("kotlin-android")
|
||||
id("jacoco")
|
||||
id("kotlin-parcelize")
|
||||
id("com.google.devtools.ksp")
|
||||
}
|
||||
// Map for the version code that gives each ABI a value.
|
||||
ext.abiCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, x86: 3, x86_64: 4]
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'jacoco'
|
||||
|
||||
// Force latest version of Jacoco, initially done to resolve https://github.com/jacoco/jacoco/issues/1155
|
||||
jacoco.toolVersion = "0.8.7"
|
||||
//Different version codes per architecture (for F-Droid support)
|
||||
android.applicationVariants.configureEach { variant ->
|
||||
variant.outputs.each { output ->
|
||||
def baseAbiVersionCode = project.ext.abiCodes.get(output.getFilter(com.android.build.OutputFile.ABI))
|
||||
if (baseAbiVersionCode != null) {
|
||||
output.versionCodeOverride = (100 * project.android.defaultConfig.versionCode) + baseAbiVersionCode
|
||||
} else {
|
||||
output.versionCodeOverride = 100 * project.android.defaultConfig.versionCode
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
android {
|
||||
|
||||
namespace 'org.pixeldroid.app'
|
||||
compileSdkVersion 33
|
||||
buildToolsVersion '33.0.0'
|
||||
compileSdk 34
|
||||
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig true
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8
|
||||
freeCompilerArgs += ["-opt-in=kotlin.RequiresOptIn"]
|
||||
}
|
||||
defaultConfig {
|
||||
minSdkVersion 23
|
||||
targetSdkVersion 33
|
||||
versionCode 19
|
||||
targetSdkVersion 34
|
||||
versionCode 33
|
||||
versionName "1.0.beta" + versionCode
|
||||
|
||||
//TODO add resConfigs("en", "fr", "ja",...) ?
|
||||
|
||||
testInstrumentationRunner "org.pixeldroid.app.testUtility.TestRunner"
|
||||
testInstrumentationRunnerArguments clearPackageData: 'true'
|
||||
}
|
||||
|
@ -77,11 +95,36 @@ android {
|
|||
proguardFiles 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
||||
splits {
|
||||
// Configures multiple APKs based on ABI.
|
||||
abi {
|
||||
|
||||
// Enables building multiple APKs per ABI.
|
||||
enable true
|
||||
|
||||
// By default all ABIs are included, so use reset() and include to specify that we only
|
||||
// want APKs for "x86", "x86_64", "arm64-v8a" and "armeabi-v7a".
|
||||
|
||||
// Resets the list of ABIs for Gradle to create APKs for to none.
|
||||
reset()
|
||||
|
||||
// Specifies a list of ABIs for Gradle to create APKs for.
|
||||
//noinspection ChromeOsAbiSupport
|
||||
include project.ext.abiCodes.keySet() as String[]
|
||||
|
||||
// Specifies that we don't want to also generate a universal APK that includes all ABIs.
|
||||
universalApk false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a string with the application_id (available in xml etc)
|
||||
*/
|
||||
android.applicationVariants.all { variant ->
|
||||
android.applicationVariants.configureEach { variant ->
|
||||
variant.resValue 'string', 'application_id', variant.applicationId
|
||||
variant.resValue "string", "versionName", variant.versionName
|
||||
}
|
||||
|
||||
testOptions {
|
||||
|
@ -106,10 +149,9 @@ android {
|
|||
}
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
dataBinding = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
apply plugin: 'kotlin-kapt'
|
||||
lint {
|
||||
//We can't expect translators to always keep up immediately:
|
||||
// don't fail if a a string is untranslated
|
||||
|
@ -123,40 +165,40 @@ android {
|
|||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.0'
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
|
||||
|
||||
/**
|
||||
* AndroidX dependencies:
|
||||
*/
|
||||
implementation 'androidx.appcompat:appcompat:1.5.1'
|
||||
implementation 'androidx.core:core-splashscreen:1.0.0'
|
||||
implementation 'androidx.core:core-ktx:1.9.0'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.core:core-splashscreen:1.0.1'
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.5.2'
|
||||
implementation 'androidx.navigation:navigation-ui-ktx:2.5.2'
|
||||
implementation "androidx.browser:browser:1.4.0"
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7'
|
||||
implementation 'androidx.navigation:navigation-ui-ktx:2.7.7'
|
||||
implementation "androidx.browser:browser:1.8.0"
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
|
||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.5.2'
|
||||
implementation 'androidx.navigation:navigation-ui-ktx:2.5.2'
|
||||
implementation 'androidx.paging:paging-runtime-ktx:3.1.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.5.1'
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.5.1"
|
||||
implementation "androidx.lifecycle:lifecycle-common-java8:2.5.1"
|
||||
implementation "androidx.annotation:annotation:1.5.0"
|
||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7'
|
||||
implementation 'androidx.navigation:navigation-ui-ktx:2.7.7'
|
||||
implementation 'androidx.paging:paging-runtime-ktx:3.2.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
|
||||
implementation "androidx.lifecycle:lifecycle-common-java8:2.7.0"
|
||||
implementation "androidx.annotation:annotation:1.7.1"
|
||||
implementation 'androidx.gridlayout:gridlayout:1.0.0'
|
||||
implementation "androidx.activity:activity-ktx:1.6.0"
|
||||
implementation 'androidx.fragment:fragment-ktx:1.5.3'
|
||||
implementation 'androidx.work:work-runtime-ktx:2.7.1'
|
||||
implementation 'androidx.media2:media2-widget:1.2.1'
|
||||
implementation 'androidx.media2:media2-player:1.2.1'
|
||||
implementation "androidx.activity:activity-ktx:1.8.2"
|
||||
implementation 'androidx.fragment:fragment-ktx:1.6.2'
|
||||
implementation 'androidx.work:work-runtime-ktx:2.9.0'
|
||||
implementation 'androidx.media2:media2-widget:1.3.0'
|
||||
implementation 'androidx.media2:media2-player:1.3.0'
|
||||
|
||||
|
||||
// Use the most recent version of CameraX
|
||||
def cameraX_version = '1.1.0'
|
||||
def cameraX_version = '1.3.2'
|
||||
implementation "androidx.camera:camera-core:$cameraX_version"
|
||||
implementation "androidx.camera:camera-camera2:$cameraX_version"
|
||||
// CameraX Lifecycle library
|
||||
|
@ -165,9 +207,9 @@ dependencies {
|
|||
// CameraX View class
|
||||
implementation "androidx.camera:camera-view:$cameraX_version"
|
||||
|
||||
def room_version = "2.4.3"
|
||||
def room_version = "2.6.1"
|
||||
implementation "androidx.room:room-runtime:$room_version"
|
||||
kapt "androidx.room:room-compiler:$room_version"
|
||||
ksp "androidx.room:room-compiler:$room_version"
|
||||
implementation "androidx.room:room-ktx:$room_version"
|
||||
implementation "androidx.room:room-paging:$room_version"
|
||||
|
||||
|
@ -177,59 +219,53 @@ dependencies {
|
|||
*/
|
||||
|
||||
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
|
||||
implementation 'com.arthenica:ffmpeg-kit-min-gpl:5.1.LTS'
|
||||
|
||||
|
||||
implementation 'com.google.android.material:material:1.7.0'
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
|
||||
//Dagger (dependency injection)
|
||||
implementation 'com.google.dagger:dagger-android:2.44'
|
||||
implementation 'com.google.dagger:dagger-android-support:2.44'
|
||||
// if you use the support libraries
|
||||
kapt 'com.google.dagger:dagger-android-processor:2.44'
|
||||
kapt 'com.google.dagger:dagger-compiler:2.44'
|
||||
implementation 'com.google.dagger:dagger:2.51'
|
||||
ksp 'com.google.dagger:dagger-compiler:2.51'
|
||||
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.3'
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
implementation 'com.squareup.retrofit2:adapter-rxjava3:2.9.0'
|
||||
implementation 'io.reactivex.rxjava3:rxjava:3.1.5'
|
||||
implementation 'io.reactivex.rxjava3:rxandroid:3.0.0'
|
||||
implementation('com.google.dagger:hilt-android:2.51')
|
||||
ksp 'com.google.dagger:hilt-compiler:2.51'
|
||||
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.10.0'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.10.0'
|
||||
implementation 'com.squareup.retrofit2:adapter-rxjava3:2.10.0'
|
||||
implementation 'io.reactivex.rxjava3:rxjava:3.1.8'
|
||||
implementation 'io.reactivex.rxjava3:rxandroid:3.0.2'
|
||||
implementation 'com.github.connyduck:sparkbutton:4.1.0'
|
||||
|
||||
|
||||
implementation 'info.androidhive:imagefilters:1.0.7'
|
||||
implementation 'com.github.yalantis:ucrop:2.2.8-native'
|
||||
implementation 'org.pixeldroid.pixeldroid:android-media-editor:2.0'
|
||||
implementation project(path: ':scrambler')
|
||||
implementation project(path: ':pixel_common')
|
||||
|
||||
implementation('com.github.bumptech.glide:glide:4.14.2') {
|
||||
implementation('com.github.bumptech.glide:glide:4.16.0') {
|
||||
exclude group: "com.android.support"
|
||||
}
|
||||
|
||||
implementation 'com.github.bumptech.glide:okhttp3-integration:4.14.2'
|
||||
implementation('com.github.bumptech.glide:recyclerview-integration:4.14.2') {
|
||||
implementation 'com.github.bumptech.glide:okhttp3-integration:4.16.0'
|
||||
implementation('com.github.bumptech.glide:recyclerview-integration:4.16.0') {
|
||||
// Excludes the support library because it's already included by Glide.
|
||||
transitive = false
|
||||
}
|
||||
implementation 'com.github.bumptech.glide:annotations:4.14.2'
|
||||
annotationProcessor 'com.github.bumptech.glide:compiler:4.14.2'
|
||||
kapt 'com.github.bumptech.glide:compiler:4.14.2'
|
||||
implementation 'com.github.bumptech.glide:annotations:4.16.0'
|
||||
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
|
||||
ksp 'com.github.bumptech.glide:ksp:4.16.0'
|
||||
|
||||
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
||||
|
||||
implementation 'com.mikepenz:materialdrawer:9.0.1'
|
||||
implementation 'com.mikepenz:materialdrawer:9.0.2'
|
||||
// Add for NavController support
|
||||
implementation 'com.mikepenz:materialdrawer-nav:9.0.1'
|
||||
implementation 'com.mikepenz:materialdrawer-nav:9.0.2'
|
||||
|
||||
//iconics
|
||||
implementation 'com.mikepenz:iconics-core:5.4.0'
|
||||
implementation 'com.mikepenz:materialdrawer-iconics:9.0.1'
|
||||
implementation 'com.mikepenz:materialdrawer-iconics:9.0.2'
|
||||
implementation 'com.mikepenz:iconics-views:5.4.0'
|
||||
implementation 'com.mikepenz:google-material-typeface:4.0.0.2-kotlin@aar'
|
||||
|
||||
|
||||
implementation 'com.karumi:dexter:6.2.3'
|
||||
|
||||
implementation 'com.github.ligi:tracedroid:4.1'
|
||||
|
||||
implementation 'me.relex:circleindicator:2.1.6'
|
||||
|
@ -244,27 +280,29 @@ dependencies {
|
|||
androidTestImplementation 'com.linkedin.testbutler:test-butler-library:2.2.1'
|
||||
androidTestUtil 'com.linkedin.testbutler:test-butler-app:2.2.1'
|
||||
|
||||
androidTestImplementation 'androidx.work:work-testing:2.7.1'
|
||||
androidTestImplementation 'androidx.work:work-testing:2.9.0'
|
||||
testImplementation 'com.github.tomakehurst:wiremock-jre8:2.34.0'
|
||||
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation "androidx.room:room-testing:$room_version"
|
||||
|
||||
|
||||
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
|
||||
androidTestImplementation 'androidx.test:runner:1.4.0'
|
||||
androidTestImplementation 'androidx.test:rules:1.4.0'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||
androidTestImplementation 'androidx.test:runner:1.4.0'
|
||||
androidTestImplementation 'androidx.test:rules:1.4.0'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0'
|
||||
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.3.0'
|
||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
|
||||
androidTestImplementation 'com.android.support.test.espresso:espresso-contrib:3.0.2'
|
||||
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.9.2'
|
||||
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
|
||||
|
||||
}
|
||||
|
||||
tasks.withType(Test) {
|
||||
|
||||
|
||||
tasks.withType(Test).configureEach {
|
||||
jacoco.includeNoLocationClasses = true
|
||||
jacoco.excludes = ['jdk.internal.*']
|
||||
}
|
||||
|
@ -273,8 +311,8 @@ tasks.withType(Test) {
|
|||
task jacocoTestReport(type: JacocoReport, dependsOn: ['connectedStagingAndroidTest', 'testStagingUnitTest', 'createStagingCoverageReport']) {
|
||||
|
||||
reports {
|
||||
xml.enabled = true
|
||||
html.enabled = true
|
||||
xml.required = true
|
||||
html.required = true
|
||||
}
|
||||
|
||||
def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', '**/*Test*.*', 'android/**/*.*']
|
||||
|
|
|
@ -925,12 +925,6 @@
|
|||
license: The Apache Software License, Version 2.0
|
||||
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
url: https://github.com/davemorrissey/subsampling-scale-image-view
|
||||
- artifact: com.arthenica:ffmpeg-kit-min:+
|
||||
name: ffmpeg-kit-min
|
||||
copyrightHolder: Taner Şener
|
||||
license: GNU Lesser General Public License, Version 3
|
||||
licenseUrl: https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
url: https://github.com/tanersener/ffmpeg-kit
|
||||
- artifact: com.arthenica:smart-exception-java:+
|
||||
name: smart-exception-java
|
||||
copyrightHolder: Taner Şener
|
||||
|
@ -949,3 +943,47 @@
|
|||
license: Simplified BSD License
|
||||
licenseUrl: http://www.opensource.org/licenses/bsd-license
|
||||
url: https://github.com/bumptech/glide
|
||||
- artifact: androidx.core:core-splashscreen:+
|
||||
name: core-splashscreen
|
||||
copyrightHolder: Google Inc.
|
||||
license: The Apache Software License, Version 2.0
|
||||
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
url: https://developer.android.com/jetpack/androidx/releases/core#1.0.0
|
||||
- artifact: androidx.databinding:databinding-adapters:+
|
||||
name: databinding-adapters
|
||||
copyrightHolder: Google Inc.
|
||||
license: The Apache Software License, Version 2.0
|
||||
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
- artifact: androidx.databinding:databinding-ktx:+
|
||||
name: databinding-ktx
|
||||
copyrightHolder: Google Inc.
|
||||
license: The Apache Software License, Version 2.0
|
||||
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
- artifact: androidx.databinding:databinding-runtime:+
|
||||
name: databinding-runtime
|
||||
copyrightHolder: Google Inc.
|
||||
license: The Apache Software License, Version 2.0
|
||||
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
- artifact: androidx.databinding:viewbinding:+
|
||||
name: viewbinding
|
||||
copyrightHolder: Google Inc.
|
||||
license: The Apache Software License, Version 2.0
|
||||
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
- artifact: com.squareup.okio:okio-jvm:+
|
||||
name: okio-jvm
|
||||
copyrightHolder: #COPYRIGHT_HOLDER#
|
||||
license: The Apache Software License, Version 2.0
|
||||
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
url: https://github.com/square/okio/
|
||||
- artifact: com.arthenica:ffmpeg-kit-min-gpl:+
|
||||
name: ffmpeg-kit-min-gpl
|
||||
copyrightHolder: Taner Şener
|
||||
license: GNU Lesser General Public License, Version 3
|
||||
licenseUrl: https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
url: https://github.com/arthenica/ffmpeg-kit
|
||||
- artifact: org.pixeldroid.pixeldroid:android-media-editor:+
|
||||
name: android-media-editor
|
||||
copyrightHolder: Matthieu De Beule
|
||||
license: GNU General Public License version 3
|
||||
licenseUrl: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
url: https://gitlab.shinice.net/pixeldroid/android-media-editor/
|
|
@ -93,6 +93,14 @@
|
|||
-keep public class * extends com.bumptech.glide.module.AppGlideModule
|
||||
-keep class com.bumptech.glide.GeneratedAppGlideModuleImpl
|
||||
|
||||
-dontwarn org.bouncycastle.**
|
||||
-dontwarn org.conscrypt.**
|
||||
-dontwarn org.openjsse.javax.net.ssl.**
|
||||
-dontwarn org.openjsse.net.ssl.**
|
||||
|
||||
-dontwarn org.checkerframework.checker.nullness.qual.EnsuresNonNull
|
||||
-dontwarn org.checkerframework.checker.nullness.qual.RequiresNonNull
|
||||
|
||||
##---------------Begin: proguard configuration for Gson ----------
|
||||
# Gson uses generic type information stored in a class file when working with fields. Proguard
|
||||
# removes such information by default, so configure it to keep all of it.
|
||||
|
@ -126,4 +134,54 @@
|
|||
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
|
||||
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken
|
||||
|
||||
##---------------End: proguard configuration for Gson ----------
|
||||
##---------------End: proguard configuration for Gson ----------
|
||||
|
||||
##---------------Begin: proguard configuration for Retrofit ----------
|
||||
# Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and
|
||||
# EnclosingMethod is required to use InnerClasses.
|
||||
-keepattributes Signature, InnerClasses, EnclosingMethod
|
||||
|
||||
# Retrofit does reflection on method and parameter annotations.
|
||||
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
|
||||
|
||||
# Keep annotation default values (e.g., retrofit2.http.Field.encoded).
|
||||
-keepattributes AnnotationDefault
|
||||
|
||||
# Retain service method parameters when optimizing.
|
||||
-keepclassmembers,allowshrinking,allowobfuscation interface * {
|
||||
@retrofit2.http.* <methods>;
|
||||
}
|
||||
|
||||
# Ignore annotation used for build tooling.
|
||||
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
|
||||
|
||||
# Ignore JSR 305 annotations for embedding nullability information.
|
||||
-dontwarn javax.annotation.**
|
||||
|
||||
# Guarded by a NoClassDefFoundError try/catch and only used when on the classpath.
|
||||
-dontwarn kotlin.Unit
|
||||
|
||||
# Top-level functions that can only be used by Kotlin.
|
||||
-dontwarn retrofit2.KotlinExtensions
|
||||
-dontwarn retrofit2.KotlinExtensions$*
|
||||
|
||||
# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy
|
||||
# and replaces all potential values with null. Explicitly keeping the interfaces prevents this.
|
||||
-if interface * { @retrofit2.http.* <methods>; }
|
||||
-keep,allowobfuscation interface <1>
|
||||
|
||||
# Keep inherited services.
|
||||
-if interface * { @retrofit2.http.* <methods>; }
|
||||
-keep,allowobfuscation interface * extends <1>
|
||||
|
||||
# Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items).
|
||||
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
|
||||
-keep,allowobfuscation,allowshrinking class retrofit2.Response
|
||||
|
||||
# With R8 full mode generic signatures are stripped for classes that are not
|
||||
# kept. Suspend functions are wrapped in continuations where the type argument
|
||||
# is used.
|
||||
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
|
||||
##---------------End: proguard configuration for Retrofit ----------
|
||||
|
||||
-keep,allowobfuscation,allowshrinking class io.reactivex.rxjava3.core.Observable
|
|
@ -8,7 +8,7 @@
|
|||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetPackage="org.pixeldroid.app.debug"
|
||||
android:targetClass="org.pixeldroid.app.postCreation.camera.CameraActivityShortcut" />
|
||||
android:targetClass="org.pixeldroid.app.postCreation.camera.CameraActivity" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
<capability-binding android:key="actions.intent.CREATE_MESSAGE" />
|
||||
</shortcut>
|
||||
|
|
|
@ -4,13 +4,14 @@
|
|||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera.any"
|
||||
android:required="false" />
|
||||
<uses-feature android:name="android.hardware.location.gps" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
|
@ -20,46 +21,49 @@
|
|||
|
||||
<application
|
||||
android:name=".utils.PixelDroidApplication"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
tools:replace="android:allowBackup">
|
||||
android:theme="@style/BaseAppTheme">
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
android:enabled="false"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name=".posts.AlbumActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/AppTheme.ActionBar.Transparent"/>
|
||||
android:theme="@style/TransparentAlbumActivity"/>
|
||||
<activity
|
||||
android:name=".postCreation.photoEdit.VideoEditActivity"
|
||||
android:exported="false"/>
|
||||
android:name=".profile.EditProfileActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/BaseAppTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".posts.MediaViewerActivity"
|
||||
android:configChanges="keyboardHidden|orientation|screenSize"
|
||||
android:exported="false"
|
||||
android:theme="@style/AppTheme.NoActionBar" />
|
||||
<activity android:name=".postCreation.camera.CameraActivity"/>
|
||||
<activity android:name=".postCreation.camera.CameraActivityShortcut"
|
||||
android:exported = "true"
|
||||
android:parentActivityName=".MainActivity">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value=".MainActivity" />
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
android:theme="@style/BaseAppTheme" />
|
||||
<activity android:name=".postCreation.camera.CameraActivity"
|
||||
android:theme="@style/BaseAppTheme"/>
|
||||
<activity
|
||||
android:name=".posts.ReportActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/BaseAppTheme"
|
||||
tools:ignore="LockedOrientationActivity" />
|
||||
<activity android:name=".postCreation.photoEdit.PhotoEditActivity" />
|
||||
|
||||
<activity
|
||||
android:name=".stories.StoriesActivity" />
|
||||
<activity
|
||||
android:name=".postCreation.PostCreationActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/AppTheme.NoActionBar">
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/BaseAppTheme">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
|
@ -72,26 +76,32 @@
|
|||
</activity>
|
||||
<activity
|
||||
android:name=".profile.FollowsActivity"
|
||||
android:theme="@style/BaseAppTheme"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
tools:ignore="LockedOrientationActivity" />
|
||||
<activity
|
||||
android:name=".posts.feeds.uncachedFeeds.hashtags.HashTagActivity"
|
||||
android:theme="@style/BaseAppTheme"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
tools:ignore="LockedOrientationActivity" />
|
||||
<activity
|
||||
android:name=".posts.PostActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
tools:ignore="LockedOrientationActivity" />
|
||||
tools:ignore="LockedOrientationActivity"
|
||||
android:theme="@style/BaseAppTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".profile.ProfileActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
tools:ignore="LockedOrientationActivity" />
|
||||
tools:ignore="LockedOrientationActivity"
|
||||
android:theme="@style/BaseAppTheme"/>
|
||||
<activity android:name=".profile.CollectionActivity"
|
||||
android:theme="@style/BaseAppTheme"/>
|
||||
<activity
|
||||
android:name=".settings.SettingsActivity"
|
||||
android:label="@string/title_activity_settings2"
|
||||
android:parentActivityName=".MainActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
tools:ignore="LockedOrientationActivity" />
|
||||
android:theme="@style/BaseAppTheme" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
@ -115,7 +125,7 @@
|
|||
android:name=".LoginActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/AppTheme.NoActionBar"
|
||||
android:theme="@style/BaseAppTheme"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
tools:ignore="LockedOrientationActivity">
|
||||
<intent-filter>
|
||||
|
@ -129,15 +139,10 @@
|
|||
android:scheme="@string/auth_scheme" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="com.yalantis.ucrop.UCropActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/AppTheme.NoActionBar"
|
||||
tools:ignore="LockedOrientationActivity" />
|
||||
<activity
|
||||
android:name=".searchDiscover.SearchActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/BaseAppTheme"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
tools:ignore="LockedOrientationActivity">
|
||||
|
@ -149,16 +154,8 @@
|
|||
android:name="android.app.searchable"
|
||||
android:resource="@xml/searchable" />
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".settings.AboutActivity"
|
||||
android:parentActivityName=".settings.SettingsActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
tools:ignore="LockedOrientationActivity" />
|
||||
<activity
|
||||
android:name=".settings.LicenseActivity"
|
||||
android:parentActivityName=".settings.AboutActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
tools:ignore="LockedOrientationActivity" />
|
||||
<activity android:name=".searchDiscover.TrendingActivity"
|
||||
android:theme="@style/BaseAppTheme" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -1,6 +1,5 @@
|
|||
package org.pixeldroid.app
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
|
@ -9,23 +8,26 @@ import android.os.Bundle
|
|||
import android.view.View
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.pixeldroid.app.databinding.ActivityLoginBinding
|
||||
import org.pixeldroid.app.utils.*
|
||||
import org.pixeldroid.app.utils.BaseActivity
|
||||
import org.pixeldroid.app.utils.api.PixelfedAPI
|
||||
import org.pixeldroid.app.utils.api.objects.Application
|
||||
import org.pixeldroid.app.utils.api.objects.Instance
|
||||
import org.pixeldroid.app.utils.api.objects.NodeInfo
|
||||
import org.pixeldroid.app.utils.db.addUser
|
||||
import org.pixeldroid.app.utils.db.storeInstance
|
||||
import org.pixeldroid.app.utils.hasInternet
|
||||
import org.pixeldroid.app.utils.normalizeDomain
|
||||
import org.pixeldroid.app.utils.notificationsWorker.makeChannelGroupId
|
||||
import org.pixeldroid.app.utils.notificationsWorker.makeNotificationChannels
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import org.pixeldroid.app.utils.openUrl
|
||||
import org.pixeldroid.app.utils.validDomain
|
||||
|
||||
/**
|
||||
Overview of the flow of the login process: (boxes are requests done in parallel,
|
||||
|
@ -42,7 +44,7 @@ since they do not depend on each other)
|
|||
|
||||
*/
|
||||
|
||||
class LoginActivity : BaseThemedWithoutBarActivity() {
|
||||
class LoginActivity : BaseActivity() {
|
||||
|
||||
companion object {
|
||||
private const val PACKAGE_ID = BuildConfig.APPLICATION_ID
|
||||
|
@ -104,13 +106,11 @@ class LoginActivity : BaseThemedWithoutBarActivity() {
|
|||
|
||||
|
||||
private fun whatsAnInstance() {
|
||||
val builder = AlertDialog.Builder(this)
|
||||
builder.apply {
|
||||
setView(layoutInflater.inflate(R.layout.whats_an_instance_explanation, null))
|
||||
setPositiveButton(android.R.string.ok) { _, _ -> }
|
||||
}
|
||||
// Create the AlertDialog
|
||||
builder.show()
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setView(layoutInflater.inflate(R.layout.whats_an_instance_explanation, null))
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> }
|
||||
// Create the AlertDialog
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun hideKeyboard() {
|
||||
|
@ -139,9 +139,7 @@ class LoginActivity : BaseThemedWithoutBarActivity() {
|
|||
pixelfedAPI.registerApplication(
|
||||
appName, "$oauthScheme://$PACKAGE_ID", SCOPE, "https://pixeldroid.org"
|
||||
)
|
||||
} catch (exception: IOException) {
|
||||
return@async null
|
||||
} catch (exception: HttpException) {
|
||||
} catch (exception: Exception) {
|
||||
return@async null
|
||||
}
|
||||
}
|
||||
|
@ -163,9 +161,7 @@ class LoginActivity : BaseThemedWithoutBarActivity() {
|
|||
}?.href ?: return@launch failedRegistration(getString(R.string.instance_error))
|
||||
|
||||
nodeInfoSchema(normalizedDomain, clientId, nodeInfoSchemaUrl)
|
||||
} catch (exception: IOException) {
|
||||
return@launch failedRegistration()
|
||||
} catch (exception: HttpException) {
|
||||
} catch (exception: Exception) {
|
||||
return@launch failedRegistration()
|
||||
}
|
||||
}
|
||||
|
@ -179,12 +175,9 @@ class LoginActivity : BaseThemedWithoutBarActivity() {
|
|||
|
||||
val nodeInfo: NodeInfo = try {
|
||||
pixelfedAPI.nodeInfoSchema(nodeInfoSchemaUrl)
|
||||
} catch (exception: IOException) {
|
||||
return@coroutineScope failedRegistration(getString(R.string.instance_error))
|
||||
} catch (exception: HttpException) {
|
||||
} catch (exception: Exception) {
|
||||
return@coroutineScope failedRegistration(getString(R.string.instance_error))
|
||||
}
|
||||
|
||||
val domain: String = try {
|
||||
if (nodeInfo.hasInstanceEndpointInfo()) {
|
||||
preferences.edit().putString("nodeInfo", Gson().toJson(nodeInfo)).remove("instance").apply()
|
||||
|
@ -192,9 +185,7 @@ class LoginActivity : BaseThemedWithoutBarActivity() {
|
|||
} else {
|
||||
val instance: Instance = try {
|
||||
pixelfedAPI.instance()
|
||||
} catch (exception: IOException) {
|
||||
return@coroutineScope failedRegistration(getString(R.string.instance_error))
|
||||
} catch (exception: HttpException) {
|
||||
} catch (exception: Exception) {
|
||||
return@coroutineScope failedRegistration(getString(R.string.instance_error))
|
||||
}
|
||||
preferences.edit().putString("instance", Gson().toJson(instance)).remove("nodeInfo").apply()
|
||||
|
@ -209,7 +200,7 @@ class LoginActivity : BaseThemedWithoutBarActivity() {
|
|||
|
||||
|
||||
if (!nodeInfo.software?.name.orEmpty().contains("pixelfed")) {
|
||||
AlertDialog.Builder(this@LoginActivity).apply {
|
||||
MaterialAlertDialogBuilder(this@LoginActivity).apply {
|
||||
setMessage(R.string.instance_not_pixelfed_warning)
|
||||
setPositiveButton(R.string.instance_not_pixelfed_continue) { _, _ ->
|
||||
promptOAuth(normalizedDomain, clientId)
|
||||
|
@ -220,7 +211,7 @@ class LoginActivity : BaseThemedWithoutBarActivity() {
|
|||
}
|
||||
}.show()
|
||||
} else if (nodeInfo.metadata?.config?.features?.mobile_apis != true) {
|
||||
AlertDialog.Builder(this@LoginActivity).apply {
|
||||
MaterialAlertDialogBuilder(this@LoginActivity).apply {
|
||||
setMessage(R.string.api_not_enabled_dialog)
|
||||
setNegativeButton(android.R.string.ok) { _, _ ->
|
||||
loadingAnimation(false)
|
||||
|
@ -257,8 +248,9 @@ class LoginActivity : BaseThemedWithoutBarActivity() {
|
|||
|
||||
//Successful authorization
|
||||
pixelfedAPI = PixelfedAPI.createFromUrl(domain)
|
||||
val nodeInfo: NodeInfo? = Gson().fromJson(preferences.getString("nodeInfo", null), NodeInfo::class.java)
|
||||
val instance: Instance? = Gson().fromJson(preferences.getString("instance", null), Instance::class.java)
|
||||
val gson = Gson()
|
||||
val nodeInfo: NodeInfo? = gson.fromJson(preferences.getString("nodeInfo", null), NodeInfo::class.java)
|
||||
val instance: Instance? = gson.fromJson(preferences.getString("instance", null), Instance::class.java)
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
|
@ -278,10 +270,7 @@ class LoginActivity : BaseThemedWithoutBarActivity() {
|
|||
domain
|
||||
)
|
||||
wipeSharedSettings()
|
||||
} catch (exception: IOException) {
|
||||
return@launch failedRegistration(getString(R.string.token_error))
|
||||
|
||||
} catch (exception: HttpException) {
|
||||
} catch (exception: Exception) {
|
||||
return@launch failedRegistration(getString(R.string.token_error))
|
||||
}
|
||||
}
|
||||
|
@ -323,9 +312,7 @@ class LoginActivity : BaseThemedWithoutBarActivity() {
|
|||
clientSecret = clientSecret
|
||||
)
|
||||
apiHolder.setToCurrentUser()
|
||||
} catch (exception: IOException) {
|
||||
return failedRegistration(getString(R.string.verify_credentials))
|
||||
} catch (exception: HttpException) {
|
||||
} catch (exception: Exception) {
|
||||
return failedRegistration(getString(R.string.verify_credentials))
|
||||
}
|
||||
|
||||
|
@ -344,11 +331,7 @@ class LoginActivity : BaseThemedWithoutBarActivity() {
|
|||
notifications.forEach{it.user_id = user.user_id; it.instance_uri = user.instance_uri}
|
||||
|
||||
db.notificationDao().insertAll(notifications)
|
||||
} catch (exception: IOException) {
|
||||
return failedRegistration(getString(R.string.login_notifications))
|
||||
} catch (exception: HttpException) {
|
||||
return failedRegistration(getString(R.string.login_notifications))
|
||||
} catch (exception: NullPointerException) {
|
||||
} catch (exception: Exception) {
|
||||
return failedRegistration(getString(R.string.login_notifications))
|
||||
}
|
||||
|
||||
|
|
|
@ -1,15 +1,22 @@
|
|||
package org.pixeldroid.app
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.view.GravityCompat
|
||||
|
@ -23,6 +30,7 @@ import androidx.viewpager2.adapter.FragmentStateAdapter
|
|||
import androidx.viewpager2.widget.ViewPager2
|
||||
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
|
@ -30,7 +38,12 @@ import com.mikepenz.materialdrawer.iconics.iconicsIcon
|
|||
import com.mikepenz.materialdrawer.model.PrimaryDrawerItem
|
||||
import com.mikepenz.materialdrawer.model.ProfileDrawerItem
|
||||
import com.mikepenz.materialdrawer.model.ProfileSettingDrawerItem
|
||||
import com.mikepenz.materialdrawer.model.interfaces.*
|
||||
import com.mikepenz.materialdrawer.model.interfaces.IProfile
|
||||
import com.mikepenz.materialdrawer.model.interfaces.descriptionRes
|
||||
import com.mikepenz.materialdrawer.model.interfaces.descriptionText
|
||||
import com.mikepenz.materialdrawer.model.interfaces.iconUrl
|
||||
import com.mikepenz.materialdrawer.model.interfaces.nameRes
|
||||
import com.mikepenz.materialdrawer.model.interfaces.nameText
|
||||
import com.mikepenz.materialdrawer.util.AbstractDrawerImageLoader
|
||||
import com.mikepenz.materialdrawer.util.DrawerImageLoader
|
||||
import com.mikepenz.materialdrawer.widget.AccountHeaderView
|
||||
|
@ -45,28 +58,28 @@ import org.pixeldroid.app.posts.feeds.cachedFeeds.postFeeds.PostFeedFragment
|
|||
import org.pixeldroid.app.profile.ProfileActivity
|
||||
import org.pixeldroid.app.searchDiscover.SearchDiscoverFragment
|
||||
import org.pixeldroid.app.settings.SettingsActivity
|
||||
import org.pixeldroid.app.utils.BaseThemedWithoutBarActivity
|
||||
import org.pixeldroid.app.utils.BaseActivity
|
||||
import org.pixeldroid.app.utils.api.objects.Notification
|
||||
import org.pixeldroid.app.utils.db.addUser
|
||||
import org.pixeldroid.app.utils.db.entities.HomeStatusDatabaseEntity
|
||||
import org.pixeldroid.app.utils.db.entities.PublicFeedStatusDatabaseEntity
|
||||
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
|
||||
import org.pixeldroid.app.utils.db.updateUserInfoDb
|
||||
import org.pixeldroid.app.utils.hasInternet
|
||||
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companion.INSTANCE_NOTIFICATION_TAG
|
||||
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companion.SHOW_NOTIFICATION_TAG
|
||||
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companion.USER_NOTIFICATION_TAG
|
||||
import org.pixeldroid.app.utils.notificationsWorker.enablePullNotifications
|
||||
import org.pixeldroid.app.utils.notificationsWorker.removeNotificationChannelsFromAccount
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import java.time.Instant
|
||||
|
||||
|
||||
class MainActivity : BaseThemedWithoutBarActivity() {
|
||||
class MainActivity : BaseActivity() {
|
||||
|
||||
private lateinit var header: AccountHeaderView
|
||||
private var user: UserDatabaseEntity? = null
|
||||
|
||||
private val model: MainActivityViewModel by viewModels()
|
||||
|
||||
companion object {
|
||||
const val ADD_ACCOUNT_IDENTIFIER: Long = -13
|
||||
}
|
||||
|
@ -75,7 +88,9 @@ class MainActivity : BaseThemedWithoutBarActivity() {
|
|||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
installSplashScreen().setOnExitAnimationListener {
|
||||
it.remove()
|
||||
}
|
||||
|
||||
// Workaround for dynamic colors not applying due to splash screen?
|
||||
DynamicColors.applyToActivityIfAvailable(this)
|
||||
|
@ -121,11 +136,22 @@ class MainActivity : BaseThemedWithoutBarActivity() {
|
|||
if(showNotification){
|
||||
binding.viewPager.currentItem = 3
|
||||
}
|
||||
|
||||
enablePullNotifications(this)
|
||||
if (ActivityCompat.checkSelfPermission(applicationContext,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) enablePullNotifications(this)
|
||||
else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val notificationPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted: Boolean ->
|
||||
if (isGranted) enablePullNotifications(this)
|
||||
}
|
||||
|
||||
// Checks if the activity was launched from a notification from another account than the
|
||||
// current active one, and if so switches to that account
|
||||
private fun notificationFromOtherUser(): Boolean {
|
||||
|
@ -179,6 +205,7 @@ class MainActivity : BaseThemedWithoutBarActivity() {
|
|||
Glide.with(this@MainActivity)
|
||||
.load(uri)
|
||||
.placeholder(placeholder)
|
||||
.circleCrop()
|
||||
.into(imageView)
|
||||
}
|
||||
|
||||
|
@ -213,7 +240,8 @@ class MainActivity : BaseThemedWithoutBarActivity() {
|
|||
primaryDrawerItem {
|
||||
nameRes = R.string.logout
|
||||
iconicsIcon = GoogleMaterial.Icon.gmd_close
|
||||
})
|
||||
},
|
||||
)
|
||||
binding.drawer.onDrawerItemClickListener = { v, drawerItem, position ->
|
||||
when (position){
|
||||
1 -> launchActivity(ProfileActivity())
|
||||
|
@ -222,6 +250,18 @@ class MainActivity : BaseThemedWithoutBarActivity() {
|
|||
}
|
||||
false
|
||||
}
|
||||
|
||||
// Closes the drawer if it is open, when we press the back button
|
||||
onBackPressedDispatcher.addCallback(this) {
|
||||
// Handle the back button event
|
||||
if(binding.drawerLayout.isDrawerOpen(GravityCompat.START)){
|
||||
binding.drawerLayout.closeDrawer(GravityCompat.START)
|
||||
}
|
||||
else {
|
||||
this.isEnabled = false
|
||||
super.onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun logOut(){
|
||||
|
@ -234,13 +274,13 @@ class MainActivity : BaseThemedWithoutBarActivity() {
|
|||
|
||||
val remainingUsers = db.userDao().getAll()
|
||||
if (remainingUsers.isEmpty()){
|
||||
//no more users, start first-time login flow
|
||||
// No more users, start first-time login flow
|
||||
launchActivity(LoginActivity(), firstTime = true)
|
||||
} else {
|
||||
val newActive = remainingUsers.first()
|
||||
db.userDao().activateUser(newActive.user_id, newActive.instance_uri)
|
||||
apiHolder.setToCurrentUser()
|
||||
//relaunch the app
|
||||
// Relaunch the app
|
||||
launchActivity(MainActivity(), firstTime = true)
|
||||
}
|
||||
}
|
||||
|
@ -251,19 +291,13 @@ class MainActivity : BaseThemedWithoutBarActivity() {
|
|||
|
||||
lifecycleScope.launchWhenCreated {
|
||||
try {
|
||||
val domain = user?.instance_uri.orEmpty()
|
||||
val accessToken = user?.accessToken.orEmpty()
|
||||
val refreshToken = user?.refreshToken
|
||||
val clientId = user?.clientId.orEmpty()
|
||||
val clientSecret = user?.clientSecret.orEmpty()
|
||||
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
|
||||
val account = api.verifyCredentials()
|
||||
addUser(db, account, domain, accessToken = accessToken, refreshToken = refreshToken, clientId = clientId, clientSecret = clientSecret)
|
||||
fillDrawerAccountInfo(account.id!!)
|
||||
} catch (exception: IOException) {
|
||||
Log.e("ACCOUNT UPDATE:", exception.toString())
|
||||
} catch (exception: HttpException) {
|
||||
updateUserInfoDb(db, account)
|
||||
|
||||
//No need to update drawer account info here, the ViewModel listens to db updates
|
||||
} catch (exception: Exception) {
|
||||
Log.e("ACCOUNT UPDATE:", exception.toString())
|
||||
}
|
||||
}
|
||||
|
@ -294,9 +328,11 @@ class MainActivity : BaseThemedWithoutBarActivity() {
|
|||
}
|
||||
|
||||
private fun switchUser(userId: String, instance_uri: String) {
|
||||
db.userDao().deActivateActiveUsers()
|
||||
db.userDao().activateUser(userId, instance_uri)
|
||||
apiHolder.setToCurrentUser()
|
||||
db.runInTransaction{
|
||||
db.userDao().deActivateActiveUsers()
|
||||
db.userDao().activateUser(userId, instance_uri)
|
||||
apiHolder.setToCurrentUser()
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun primaryDrawerItem(block: PrimaryDrawerItem.() -> Unit): PrimaryDrawerItem {
|
||||
|
@ -309,35 +345,41 @@ class MainActivity : BaseThemedWithoutBarActivity() {
|
|||
}
|
||||
|
||||
private fun fillDrawerAccountInfo(account: String) {
|
||||
val users = db.userDao().getAll().toMutableList()
|
||||
users.sortWith { l, r ->
|
||||
when {
|
||||
l.isActive && !r.isActive -> -1
|
||||
r.isActive && !l.isActive -> 1
|
||||
else -> 0
|
||||
lifecycleScope.launch {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
model.users.collect { list ->
|
||||
val users = list.toMutableList()
|
||||
users.sortWith { l, r ->
|
||||
when {
|
||||
l.isActive && !r.isActive -> -1
|
||||
r.isActive && !l.isActive -> 1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
val profiles: MutableList<IProfile> = users.map { user ->
|
||||
ProfileDrawerItem().apply {
|
||||
isSelected = user.isActive
|
||||
nameText = user.display_name
|
||||
iconUrl = user.avatar_static
|
||||
isNameShown = true
|
||||
identifier = user.user_id.toLong()
|
||||
descriptionText = user.fullHandle
|
||||
tag = user.instance_uri
|
||||
}
|
||||
}.toMutableList()
|
||||
|
||||
// reuse the already existing "add account" item
|
||||
header.profiles.orEmpty()
|
||||
.filter { it.identifier == ADD_ACCOUNT_IDENTIFIER }
|
||||
.take(1)
|
||||
.forEach { profiles.add(it) }
|
||||
|
||||
header.clear()
|
||||
header.profiles = profiles
|
||||
header.setActiveProfile(account.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
val profiles: MutableList<IProfile> = users.map { user ->
|
||||
ProfileDrawerItem().apply {
|
||||
isSelected = user.isActive
|
||||
nameText = user.display_name
|
||||
iconUrl = user.avatar_static
|
||||
isNameShown = true
|
||||
identifier = user.user_id.toLong()
|
||||
descriptionText = user.fullHandle
|
||||
tag = user.instance_uri
|
||||
}
|
||||
}.toMutableList()
|
||||
|
||||
// reuse the already existing "add account" item
|
||||
header.profiles.orEmpty()
|
||||
.filter { it.identifier == ADD_ACCOUNT_IDENTIFIER }
|
||||
.take(1)
|
||||
.forEach { profiles.add(it) }
|
||||
|
||||
header.clear()
|
||||
header.profiles = profiles
|
||||
header.setActiveProfile(account.toLong())
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -429,14 +471,9 @@ class MainActivity : BaseThemedWithoutBarActivity() {
|
|||
}
|
||||
val numberOfNewNotifications = if((filtered?.size ?: 20) >= 20) null else filtered?.size
|
||||
if(filtered?.isNotEmpty() == true ) setNotificationBadge(true, numberOfNewNotifications)
|
||||
} catch (exception: IOException) {
|
||||
return@repeatOnLifecycle
|
||||
} catch (exception: HttpException) {
|
||||
} catch (exception: Exception) {
|
||||
return@repeatOnLifecycle
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -471,16 +508,4 @@ class MainActivity : BaseThemedWithoutBarActivity() {
|
|||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the drawer if it is open, when we press the back button
|
||||
*/
|
||||
override fun onBackPressed() {
|
||||
if(binding.drawerLayout.isDrawerOpen(GravityCompat.START)){
|
||||
binding.drawerLayout.closeDrawer(GravityCompat.START)
|
||||
} else {
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
package org.pixeldroid.app
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.pixeldroid.app.utils.db.AppDatabase
|
||||
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class MainActivityViewModel @Inject constructor(
|
||||
private val db: AppDatabase
|
||||
): ViewModel() {
|
||||
|
||||
// Mutable state flow that will be used internally in the ViewModel, empty list is given as initial value.
|
||||
private val _users = MutableStateFlow(emptyList<UserDatabaseEntity>())
|
||||
|
||||
// Immutable state flow exposed to UI
|
||||
val users = _users.asStateFlow()
|
||||
|
||||
|
||||
init {
|
||||
getUsers()
|
||||
}
|
||||
|
||||
private fun getUsers() {
|
||||
viewModelScope.launch {
|
||||
db.userDao().getAllFlow().flowOn(Dispatchers.IO)
|
||||
.collect { users: List<UserDatabaseEntity> ->
|
||||
_users.update { users }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,538 +1,65 @@
|
|||
package org.pixeldroid.app.postCreation
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.*
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.View.INVISIBLE
|
||||
import android.view.View.VISIBLE
|
||||
import android.view.View.GONE
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.HandlerCompat
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.arthenica.ffmpegkit.*
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.launch
|
||||
import android.os.Bundle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.ActivityPostCreationBinding
|
||||
import org.pixeldroid.app.postCreation.camera.CameraActivity
|
||||
import org.pixeldroid.app.postCreation.carousel.CarouselItem
|
||||
import org.pixeldroid.app.postCreation.photoEdit.PhotoEditActivity
|
||||
import org.pixeldroid.app.postCreation.photoEdit.VideoEditActivity
|
||||
import org.pixeldroid.app.utils.BaseThemedWithoutBarActivity
|
||||
import org.pixeldroid.app.utils.convert
|
||||
import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity
|
||||
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
|
||||
import org.pixeldroid.app.utils.ffmpegCompliantUri
|
||||
import org.pixeldroid.app.utils.fileExtension
|
||||
import org.pixeldroid.app.utils.getMimeType
|
||||
import java.io.File
|
||||
import java.io.OutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.roundToInt
|
||||
import org.pixeldroid.app.utils.BaseActivity
|
||||
|
||||
const val TAG = "Post Creation Activity"
|
||||
class PostCreationActivity : BaseActivity() {
|
||||
|
||||
data class PhotoData(
|
||||
var imageUri: Uri,
|
||||
var size: Long,
|
||||
var uploadId: String? = null,
|
||||
var progress: Int? = null,
|
||||
var imageDescription: String? = null,
|
||||
var video: Boolean,
|
||||
var videoEncodeProgress: Int? = null,
|
||||
var videoEncodeStabilizationFirstPass: Boolean? = null,
|
||||
)
|
||||
companion object {
|
||||
internal const val POST_DESCRIPTION = "post_description"
|
||||
internal const val PICTURE_DESCRIPTIONS = "picture_descriptions"
|
||||
internal const val POST_REDRAFT = "post_redraft"
|
||||
internal const val POST_NSFW = "post_nsfw"
|
||||
internal const val TEMP_FILES = "temp_files"
|
||||
|
||||
class PostCreationActivity : BaseThemedWithoutBarActivity() {
|
||||
fun intentForUris(context: Context, uris: List<Uri>) =
|
||||
Intent(Intent.ACTION_SEND_MULTIPLE).apply {
|
||||
// Pass downloaded images to new post creation activity
|
||||
putParcelableArrayListExtra(
|
||||
Intent.EXTRA_STREAM, ArrayList(uris)
|
||||
)
|
||||
|
||||
private var user: UserDatabaseEntity? = null
|
||||
private lateinit var instance: InstanceDatabaseEntity
|
||||
uris.forEach {
|
||||
// Why are we using ClipData in addition to parcelableArrayListExtra here?
|
||||
// Because the FLAG_GRANT_READ_URI_PERMISSION needs to be applied to the URIs, and
|
||||
// for some reason it doesn't get applied to all of them when not using ClipData
|
||||
if (clipData == null) {
|
||||
clipData = ClipData("", emptyArray(), ClipData.Item(it))
|
||||
} else {
|
||||
clipData!!.addItem(ClipData.Item(it))
|
||||
}
|
||||
}
|
||||
|
||||
setClass(context, PostCreationActivity::class.java)
|
||||
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private lateinit var binding: ActivityPostCreationBinding
|
||||
|
||||
private val resultHandler: Handler = HandlerCompat.createAsync(Looper.getMainLooper())
|
||||
|
||||
private lateinit var model: PostCreationViewModel
|
||||
|
||||
private lateinit var navController: NavController
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
binding = ActivityPostCreationBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
user = db.userDao().getActiveUser()
|
||||
|
||||
instance = user?.run {
|
||||
db.instanceDao().getAll().first { instanceDatabaseEntity ->
|
||||
instanceDatabaseEntity.uri.contains(instance_uri)
|
||||
}
|
||||
} ?: InstanceDatabaseEntity("", "")
|
||||
|
||||
val _model: PostCreationViewModel by viewModels { PostCreationViewModelFactory(application, intent.clipData!!, instance) }
|
||||
model = _model
|
||||
|
||||
model.getPhotoData().observe(this) { newPhotoData ->
|
||||
// update UI
|
||||
binding.carousel.addData(
|
||||
newPhotoData.map {
|
||||
CarouselItem(it.imageUri, it.imageDescription, it.video, it.videoEncodeProgress, it.videoEncodeStabilizationFirstPass)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
//Get initial text value from model (for template)
|
||||
binding.newPostDescriptionInputField.setText(model.uiState.value.newPostDescriptionText)
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
model.uiState.collect { uiState ->
|
||||
uiState.userMessage?.let {
|
||||
AlertDialog.Builder(binding.root.context).apply {
|
||||
setMessage(it)
|
||||
setNegativeButton(android.R.string.ok) { _, _ -> }
|
||||
}.show()
|
||||
|
||||
// Notify the ViewModel the message is displayed
|
||||
model.userMessageShown()
|
||||
}
|
||||
binding.addPhotoButton.isEnabled = uiState.addPhotoButtonEnabled
|
||||
enableButton(uiState.postCreationSendButtonEnabled)
|
||||
binding.uploadProgressBar.visibility = if(uiState.uploadProgressBarVisible) VISIBLE else INVISIBLE
|
||||
binding.uploadProgressBar.progress = uiState.uploadProgress
|
||||
binding.uploadCompletedTextview.visibility = if(uiState.uploadCompletedTextviewVisible) VISIBLE else INVISIBLE
|
||||
binding.removePhotoButton.isEnabled = uiState.removePhotoButtonEnabled
|
||||
binding.editPhotoButton.isEnabled = uiState.editPhotoButtonEnabled
|
||||
binding.uploadError.visibility = if(uiState.uploadErrorVisible) VISIBLE else INVISIBLE
|
||||
binding.uploadErrorTextExplanation.visibility = if(uiState.uploadErrorExplanationVisible) VISIBLE else INVISIBLE
|
||||
|
||||
binding.toolbarPostCreation.visibility = if(uiState.isCarousel) VISIBLE else INVISIBLE
|
||||
binding.carousel.layoutCarousel = uiState.isCarousel
|
||||
|
||||
|
||||
binding.uploadErrorTextExplanation.text = uiState.uploadErrorExplanationText
|
||||
|
||||
uiState.newEncodingJobPosition?.let { position ->
|
||||
uiState.newEncodingJobMuted?.let { muted ->
|
||||
uiState.newEncodingJobVideoStart.let { videoStart ->
|
||||
uiState.newEncodingJobVideoEnd.let { videoEnd ->
|
||||
uiState.newEncodingJobSpeedIndex?.let { speedIndex ->
|
||||
uiState.newEncodingJobVideoCrop?.let { crop ->
|
||||
uiState.newEncodingJobStabilize?.let { stabilize ->
|
||||
startEncoding(position, muted,
|
||||
videoStart, videoEnd,
|
||||
speedIndex, crop, stabilize,
|
||||
)
|
||||
model.encodingStarted()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.newPostDescriptionInputField.doAfterTextChanged {
|
||||
model.newPostDescriptionChanged(binding.newPostDescriptionInputField.text)
|
||||
}
|
||||
binding.postTextInputLayout.counterMaxLength = instance.maxStatusChars
|
||||
|
||||
binding.carousel.apply {
|
||||
layoutCarouselCallback = { model.becameCarousel(it)}
|
||||
maxEntries = instance.albumLimit
|
||||
addPhotoButtonCallback = {
|
||||
addPhoto()
|
||||
}
|
||||
updateDescriptionCallback = { position: Int, description: String ->
|
||||
model.updateDescription(position, description)
|
||||
}
|
||||
}
|
||||
// get the description and send the post
|
||||
binding.postCreationSendButton.setOnClickListener {
|
||||
if (validatePost() && model.isNotEmpty()) model.upload()
|
||||
}
|
||||
|
||||
// Button to retry image upload when it fails
|
||||
binding.retryUploadButton.setOnClickListener {
|
||||
model.resetUploadStatus()
|
||||
model.upload()
|
||||
}
|
||||
|
||||
binding.editPhotoButton.setOnClickListener {
|
||||
binding.carousel.currentPosition.takeIf { it != RecyclerView.NO_POSITION }?.let { currentPosition ->
|
||||
edit(currentPosition)
|
||||
}
|
||||
}
|
||||
|
||||
binding.addPhotoButton.setOnClickListener {
|
||||
addPhoto()
|
||||
}
|
||||
|
||||
binding.savePhotoButton.setOnClickListener {
|
||||
binding.carousel.currentPosition.takeIf { it != RecyclerView.NO_POSITION }?.let { currentPosition ->
|
||||
savePicture(it, currentPosition)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
binding.removePhotoButton.setOnClickListener {
|
||||
binding.carousel.currentPosition.takeIf { it != RecyclerView.NO_POSITION }?.let { currentPosition ->
|
||||
model.removeAt(currentPosition)
|
||||
model.cancelEncode(currentPosition)
|
||||
}
|
||||
}
|
||||
val navHostFragment =
|
||||
supportFragmentManager.findFragmentById(R.id.postCreationContainer) as NavHostFragment
|
||||
navController = navHostFragment.navController
|
||||
navController.setGraph(R.navigation.post_creation_graph)
|
||||
}
|
||||
|
||||
private val addPhotoResultContract = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK && result.data?.clipData != null) {
|
||||
result.data?.clipData?.let {
|
||||
model.setImages(model.addPossibleImages(it))
|
||||
}
|
||||
} else if (result.resultCode != Activity.RESULT_CANCELED) {
|
||||
Toast.makeText(applicationContext, R.string.add_images_error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
override fun onSupportNavigateUp() = navController.navigateUp() || super.onSupportNavigateUp()
|
||||
|
||||
private fun addPhoto(){
|
||||
addPhotoResultContract.launch(
|
||||
Intent(this, CameraActivity::class.java)
|
||||
)
|
||||
}
|
||||
|
||||
private fun savePicture(button: View, currentPosition: Int) {
|
||||
val originalUri = model.getPhotoData().value!![currentPosition].imageUri
|
||||
|
||||
val pair = getOutputFile(originalUri)
|
||||
val outputStream: OutputStream = pair.first
|
||||
val path: String = pair.second
|
||||
|
||||
contentResolver.openInputStream(originalUri)!!.use { input ->
|
||||
outputStream.use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
if(path.startsWith("file")) {
|
||||
MediaScannerConnection.scanFile(
|
||||
this,
|
||||
arrayOf(path.toUri().toFile().absolutePath),
|
||||
null
|
||||
) { path, uri ->
|
||||
if (uri == null) {
|
||||
Log.e(
|
||||
"NEW IMAGE SCAN FAILED",
|
||||
"Tried to scan $path, but it failed"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Snackbar.make(
|
||||
button, getString(R.string.save_image_success),
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
|
||||
private fun getOutputFile(uri: Uri): Pair<OutputStream, String> {
|
||||
val extension = uri.fileExtension(contentResolver)
|
||||
|
||||
val name = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", Locale.US)
|
||||
.format(System.currentTimeMillis()) + ".$extension"
|
||||
|
||||
val outputStream: OutputStream
|
||||
val path: String
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val resolver: ContentResolver = contentResolver
|
||||
val type = uri.getMimeType(contentResolver)
|
||||
val contentValues = ContentValues()
|
||||
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name)
|
||||
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, type)
|
||||
contentValues.put(
|
||||
MediaStore.MediaColumns.RELATIVE_PATH,
|
||||
Environment.DIRECTORY_PICTURES
|
||||
)
|
||||
val store =
|
||||
if (type.startsWith("video")) MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
else MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
val imageUri: Uri = resolver.insert(store, contentValues)!!
|
||||
path = imageUri.toString()
|
||||
outputStream = resolver.openOutputStream(Objects.requireNonNull(imageUri))!!
|
||||
} else {
|
||||
@Suppress("DEPRECATION") val imagesDir =
|
||||
Environment.getExternalStoragePublicDirectory(getString(R.string.app_name))
|
||||
imagesDir.mkdir()
|
||||
val file = File(imagesDir, name)
|
||||
path = Uri.fromFile(file).toString()
|
||||
outputStream = file.outputStream()
|
||||
}
|
||||
return Pair(outputStream, path)
|
||||
}
|
||||
|
||||
|
||||
private fun validatePost(): Boolean {
|
||||
binding.postTextInputLayout.run {
|
||||
val content = editText?.length() ?: 0
|
||||
if (content > counterMaxLength) {
|
||||
// error, too many characters
|
||||
error = resources.getQuantityString(R.plurals.description_max_characters, counterMaxLength, counterMaxLength)
|
||||
return false
|
||||
}
|
||||
}
|
||||
if(model.getPhotoData().value?.all { it.videoEncodeProgress == null } == false){
|
||||
AlertDialog.Builder(this).apply {
|
||||
setMessage(R.string.still_encoding)
|
||||
setNegativeButton(android.R.string.ok) { _, _ -> }
|
||||
}.show()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun enableButton(enable: Boolean = true){
|
||||
binding.postCreationSendButton.isEnabled = enable
|
||||
if(enable){
|
||||
binding.postingProgressBar.visibility = GONE
|
||||
binding.postCreationSendButton.visibility = VISIBLE
|
||||
} else {
|
||||
binding.postingProgressBar.visibility = VISIBLE
|
||||
binding.postCreationSendButton.visibility = GONE
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val editResultContract: ActivityResultLauncher<Intent> = registerForActivityResult(ActivityResultContracts.StartActivityForResult()){
|
||||
result: ActivityResult? ->
|
||||
if (result?.resultCode == Activity.RESULT_OK && result.data != null) {
|
||||
val position: Int = result.data!!.getIntExtra(PhotoEditActivity.PICTURE_POSITION, 0)
|
||||
model.modifyAt(position, result.data!!)
|
||||
?: Toast.makeText(applicationContext, R.string.error_editing, Toast.LENGTH_SHORT).show()
|
||||
} else if(result?.resultCode != Activity.RESULT_CANCELED){
|
||||
Toast.makeText(applicationContext, R.string.error_editing, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param muted should audio tracks be removed in the output
|
||||
* @param videoStart when we want to start the video, in seconds, or null if we
|
||||
* don't want to remove the start
|
||||
* @param videoEnd when we want to end the video, in seconds, or null if we
|
||||
* don't want to remove the end
|
||||
*/
|
||||
private fun startEncoding(
|
||||
position: Int,
|
||||
muted: Boolean,
|
||||
videoStart: Float?,
|
||||
videoEnd: Float?,
|
||||
speedIndex: Int,
|
||||
crop: VideoEditActivity.RelativeCropPosition,
|
||||
stabilize: Float
|
||||
) {
|
||||
val originalUri = model.getPhotoData().value!![position].imageUri
|
||||
|
||||
// Having a meaningful suffix is necessary so that ffmpeg knows what to put in output
|
||||
val suffix = originalUri.fileExtension(contentResolver)
|
||||
val file = File.createTempFile("temp_video", ".$suffix", cacheDir)
|
||||
//val file = File.createTempFile("temp_video", ".webm", cacheDir)
|
||||
model.trackTempFile(file)
|
||||
val fileUri = file.toUri()
|
||||
val outputVideoPath = ffmpegCompliantUri(fileUri)
|
||||
|
||||
val inputUri = model.getPhotoData().value!![position].imageUri
|
||||
|
||||
val ffmpegCompliantUri: String = ffmpegCompliantUri(inputUri)
|
||||
|
||||
val mediaInformation: MediaInformation? = FFprobeKit.getMediaInformation(ffmpegCompliantUri(inputUri)).mediaInformation
|
||||
val totalVideoDuration = mediaInformation?.duration?.toFloatOrNull()
|
||||
|
||||
fun secondPass(stabilizeString: String = ""){
|
||||
val speed = VideoEditActivity.speedChoices[speedIndex]
|
||||
|
||||
val mutedString = if(muted || speedIndex != 1) "-an" else null
|
||||
val startString: List<String?> = if(videoStart != null) listOf("-ss", "${videoStart/speed.toFloat()}") else listOf(null, null)
|
||||
|
||||
val endString: List<String?> = if(videoEnd != null) listOf("-to", "${videoEnd/speed.toFloat() - (videoStart ?: 0f)/speed.toFloat()}") else listOf(null, null)
|
||||
|
||||
// iw and ih are variables for the original width and height values, FFmpeg will know them
|
||||
val cropString = if(crop.notCropped()) "" else "crop=${crop.relativeWidth}*iw:${crop.relativeHeight}*ih:${crop.relativeX}*iw:${crop.relativeY}*ih"
|
||||
val separator = if(speedIndex != 1 && !crop.notCropped()) "," else ""
|
||||
val speedString = if(speedIndex != 1) "setpts=PTS/${speed}" else ""
|
||||
|
||||
val separatorStabilize = if(stabilizeString == "" || (speedString == "" && cropString == "")) "" else ","
|
||||
|
||||
val speedAndCropString: List<String?> = if(speedIndex!= 1 || !crop.notCropped() || stabilizeString.isNotEmpty())
|
||||
listOf("-filter:v", stabilizeString + separatorStabilize + speedString + separator + cropString)
|
||||
// Stream copy is not compatible with filter, but when not filtering we can copy the stream without re-encoding
|
||||
else listOf("-c", "copy")
|
||||
|
||||
// This should be set when re-encoding is required (otherwise it defaults to mpeg which then doesn't play)
|
||||
val encodePreset: List<String?> = if(speedIndex != 1 && !crop.notCropped()) listOf("-c:v", "libx264", "-preset", "ultrafast") else listOf(null, null, null, null)
|
||||
|
||||
val session: FFmpegSession =
|
||||
FFmpegKit.executeWithArgumentsAsync(listOfNotNull(
|
||||
startString[0], startString[1],
|
||||
"-i", ffmpegCompliantUri,
|
||||
speedAndCropString[0], speedAndCropString[1],
|
||||
endString[0], endString[1],
|
||||
mutedString, "-y",
|
||||
encodePreset[0], encodePreset[1], encodePreset[2], encodePreset[3],
|
||||
outputVideoPath,
|
||||
).toTypedArray(),
|
||||
//val session: FFmpegSession = FFmpegKit.executeAsync("$startString -i $inputSafePath $endString -c:v libvpx-vp9 -c:a copy -an -y $outputVideoPath",
|
||||
{ session ->
|
||||
val returnCode = session.returnCode
|
||||
if (ReturnCode.isSuccess(returnCode)) {
|
||||
fun successResult() {
|
||||
// Hide progress indicator in carousel
|
||||
binding.carousel.updateProgress(null, position, false)
|
||||
val (imageSize, _) = outputVideoPath.toUri().let {
|
||||
model.setUriAtPosition(it, position)
|
||||
model.getSizeAndVideoValidate(it, position)
|
||||
}
|
||||
model.setVideoEncodeAtPosition(position, null)
|
||||
model.setSizeAtPosition(imageSize, position)
|
||||
}
|
||||
|
||||
val post = resultHandler.post {
|
||||
successResult()
|
||||
}
|
||||
if(!post) {
|
||||
Log.e(TAG, "Failed to post changes, trying to recover in 100ms")
|
||||
resultHandler.postDelayed({successResult()}, 100)
|
||||
}
|
||||
Log.d(TAG, "Encode completed successfully in ${session.duration} milliseconds")
|
||||
} else {
|
||||
resultHandler.post {
|
||||
binding.carousel.updateProgress(null, position, error = true)
|
||||
model.setVideoEncodeAtPosition(position, null)
|
||||
}
|
||||
Log.e(TAG, "Encode failed with state ${session.state} and rc $returnCode.${session.failStackTrace}")
|
||||
}
|
||||
},
|
||||
{ log -> Log.d("PostCreationActivityEncoding", log.message) }
|
||||
) { statistics: Statistics? ->
|
||||
|
||||
val timeInMilliseconds: Int? = statistics?.time
|
||||
timeInMilliseconds?.let {
|
||||
if (timeInMilliseconds > 0) {
|
||||
val completePercentage = totalVideoDuration?.let {
|
||||
val speedupDurationModifier = VideoEditActivity.speedChoices[speedIndex].toFloat()
|
||||
|
||||
val newTotalDuration = (it - (videoStart ?: 0f) - (it - (videoEnd ?: it)))/speedupDurationModifier
|
||||
timeInMilliseconds / (10*newTotalDuration)
|
||||
}
|
||||
resultHandler.post {
|
||||
completePercentage?.let {
|
||||
val rounded: Int = it.roundToInt()
|
||||
model.setVideoEncodeAtPosition(position, rounded)
|
||||
binding.carousel.updateProgress(rounded, position, false)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Encoding video: %$completePercentage.")
|
||||
}
|
||||
}
|
||||
}
|
||||
model.registerNewFFmpegSession(position, session.sessionId)
|
||||
}
|
||||
|
||||
fun stabilizationFirstPass(){
|
||||
|
||||
val shakeResultsFile = File.createTempFile("temp_shake_results", ".trf", cacheDir)
|
||||
model.trackTempFile(shakeResultsFile)
|
||||
val shakeResultsFileUri = shakeResultsFile.toUri()
|
||||
val shakeResultsFileSafeUri = ffmpegCompliantUri(shakeResultsFileUri).removePrefix("file://")
|
||||
|
||||
val inputSafeUri: String = ffmpegCompliantUri(inputUri)
|
||||
|
||||
// Map chosen "stabilization force" to shakiness, from 3 to 10
|
||||
val shakiness = (0f..100f).convert(stabilize, 3f..10f).roundToInt()
|
||||
|
||||
val analyzeVideoCommandList = listOf(
|
||||
"-y", "-i", inputSafeUri,
|
||||
"-vf", "vidstabdetect=shakiness=$shakiness:accuracy=15:result=$shakeResultsFileSafeUri",
|
||||
"-f", "null", "-"
|
||||
).toTypedArray()
|
||||
|
||||
FFmpegKit.executeWithArgumentsAsync(analyzeVideoCommandList,
|
||||
{ firstPass ->
|
||||
if (ReturnCode.isSuccess(firstPass.returnCode)) {
|
||||
// Map chosen "stabilization force" to shakiness, from 8 to 40
|
||||
val smoothing = (0f..100f).convert(stabilize, 8f..40f).roundToInt()
|
||||
|
||||
val stabilizeVideoCommand =
|
||||
"vidstabtransform=smoothing=$smoothing:input=${ffmpegCompliantUri(shakeResultsFileUri).removePrefix("file://")}"
|
||||
secondPass(stabilizeVideoCommand)
|
||||
} else {
|
||||
Log.e(
|
||||
"PostCreationActivityEncoding",
|
||||
"Video stabilization first pass failed!"
|
||||
)
|
||||
}
|
||||
},
|
||||
{ log -> Log.d("PostCreationActivityEncoding", log.message) },
|
||||
{ statistics: Statistics? ->
|
||||
|
||||
val timeInMilliseconds: Int? = statistics?.time
|
||||
timeInMilliseconds?.let {
|
||||
if (timeInMilliseconds > 0) {
|
||||
val completePercentage = totalVideoDuration?.let {
|
||||
val speedupDurationModifier =
|
||||
VideoEditActivity.speedChoices[speedIndex].toFloat()
|
||||
|
||||
val newTotalDuration = (it - (videoStart ?: 0f) - (it - (videoEnd
|
||||
?: it))) / speedupDurationModifier
|
||||
timeInMilliseconds / (10 * newTotalDuration)
|
||||
}
|
||||
resultHandler.post {
|
||||
completePercentage?.let {
|
||||
val rounded: Int = it.roundToInt()
|
||||
model.setVideoEncodeAtPosition(position, rounded, true)
|
||||
binding.carousel.updateProgress(rounded, position, false)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Stabilization pass: %$completePercentage.")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if(stabilize > 0.01f) {
|
||||
// Stabilization was requested: we need an additional first pass to get stabilization data
|
||||
stabilizationFirstPass()
|
||||
} else {
|
||||
// Immediately call the second pass, no stabilization needed
|
||||
secondPass()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun edit(position: Int) {
|
||||
val intent = Intent(
|
||||
this,
|
||||
if(model.getPhotoData().value!![position].video) VideoEditActivity::class.java else PhotoEditActivity::class.java
|
||||
)
|
||||
.putExtra(PhotoEditActivity.PICTURE_URI, model.getPhotoData().value!![position].imageUri)
|
||||
.putExtra(PhotoEditActivity.PICTURE_POSITION, position)
|
||||
|
||||
editResultContract.launch(intent)
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,330 @@
|
|||
package org.pixeldroid.app.postCreation
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Intent
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.launch
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.FragmentPostCreationBinding
|
||||
import org.pixeldroid.app.postCreation.camera.CameraActivity
|
||||
import org.pixeldroid.app.postCreation.carousel.CarouselItem
|
||||
import org.pixeldroid.app.utils.BaseFragment
|
||||
import org.pixeldroid.app.utils.bindingLifecycleAware
|
||||
import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity
|
||||
import org.pixeldroid.app.utils.fileExtension
|
||||
import org.pixeldroid.app.utils.getMimeType
|
||||
import org.pixeldroid.media_editor.common.PICTURE_POSITION
|
||||
import org.pixeldroid.media_editor.common.PICTURE_URI
|
||||
import org.pixeldroid.media_editor.photoEdit.PhotoEditActivity
|
||||
import org.pixeldroid.media_editor.videoEdit.VideoEditActivity
|
||||
import java.io.File
|
||||
import java.io.OutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class PostCreationFragment : BaseFragment() {
|
||||
private var binding: FragmentPostCreationBinding by bindingLifecycleAware()
|
||||
private val model: PostCreationViewModel by activityViewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
super.onCreateView(inflater, container, savedInstanceState)
|
||||
|
||||
// Inflate the layout for this fragment
|
||||
binding = FragmentPostCreationBinding.inflate(layoutInflater)
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val user = db.userDao().getActiveUser()
|
||||
|
||||
val instance = user?.run {
|
||||
db.instanceDao().getInstance(instance_uri)
|
||||
} ?: InstanceDatabaseEntity("", "")
|
||||
|
||||
model.getPhotoData().observe(viewLifecycleOwner) { newPhotoData: MutableList<PhotoData>? ->
|
||||
// update UI
|
||||
binding.carousel.addData(
|
||||
newPhotoData.orEmpty().map {
|
||||
CarouselItem(
|
||||
it.imageUri, it.imageDescription, it.video,
|
||||
it.videoEncodeProgress, it.videoEncodeStabilizationFirstPass,
|
||||
it.videoEncodeComplete, it.videoEncodeError,
|
||||
)
|
||||
}
|
||||
)
|
||||
binding.postCreationNextButton.isEnabled = newPhotoData?.isNotEmpty() ?: false
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
model.uiState.collect { uiState ->
|
||||
uiState.userMessage?.let {
|
||||
MaterialAlertDialogBuilder(binding.root.context).apply {
|
||||
setMessage(it)
|
||||
setNegativeButton(android.R.string.ok) { _, _ -> }
|
||||
}.show()
|
||||
|
||||
// Notify the ViewModel the message is displayed
|
||||
model.userMessageShown()
|
||||
}
|
||||
binding.addPhotoButton.isEnabled = uiState.addPhotoButtonEnabled
|
||||
binding.removePhotoButton.isEnabled = uiState.removePhotoButtonEnabled
|
||||
binding.editPhotoButton.isEnabled = uiState.editPhotoButtonEnabled
|
||||
binding.toolbarPostCreation.visibility =
|
||||
if (uiState.isCarousel) View.VISIBLE else View.INVISIBLE
|
||||
binding.carousel.layoutCarousel = uiState.isCarousel
|
||||
|
||||
if(uiState.storyCreation){
|
||||
binding.toggleStoryPost.check(binding.buttonStory.id)
|
||||
binding.buttonStory.isPressed = true
|
||||
binding.carousel.showLayoutSwitchButton = false
|
||||
binding.carousel.showIndicator = false
|
||||
} else {
|
||||
binding.toggleStoryPost.check(binding.buttonPost.id)
|
||||
binding.carousel.showLayoutSwitchButton = true
|
||||
binding.carousel.showIndicator = true
|
||||
}
|
||||
binding.carousel.maxEntries = uiState.maxEntries
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.carousel.apply {
|
||||
layoutCarouselCallback = { model.becameCarousel(it)}
|
||||
maxEntries = if(model.uiState.value.storyCreation) 1 else instance.albumLimit
|
||||
addPhotoButtonCallback = {
|
||||
addPhoto()
|
||||
}
|
||||
updateDescriptionCallback = { position: Int, description: String ->
|
||||
model.updateDescription(position, description)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the post and go to the next step of the post creation process
|
||||
binding.postCreationNextButton.setOnClickListener {
|
||||
if (validatePost()) {
|
||||
findNavController().navigate(R.id.action_postCreationFragment_to_postSubmissionFragment)
|
||||
}
|
||||
}
|
||||
|
||||
binding.editPhotoButton.setOnClickListener {
|
||||
binding.carousel.currentPosition.takeIf { it != RecyclerView.NO_POSITION }?.let { currentPosition ->
|
||||
edit(currentPosition)
|
||||
}
|
||||
}
|
||||
|
||||
binding.addPhotoButton.setOnClickListener {
|
||||
addPhoto()
|
||||
}
|
||||
|
||||
binding.savePhotoButton.setOnClickListener {
|
||||
binding.carousel.currentPosition.takeIf { it != RecyclerView.NO_POSITION }?.let { currentPosition ->
|
||||
savePicture(it, currentPosition)
|
||||
}
|
||||
}
|
||||
|
||||
binding.removePhotoButton.setOnClickListener {
|
||||
binding.carousel.currentPosition.takeIf { it != RecyclerView.NO_POSITION }?.let { currentPosition ->
|
||||
model.removeAt(currentPosition)
|
||||
model.cancelEncode(currentPosition)
|
||||
}
|
||||
}
|
||||
|
||||
binding.toggleStoryPost.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||
// Only handle checked events
|
||||
if (!isChecked) return@addOnButtonCheckedListener
|
||||
|
||||
when (checkedId) {
|
||||
R.id.buttonStory -> {
|
||||
model.storyMode(true)
|
||||
}
|
||||
R.id.buttonPost -> {
|
||||
model.storyMode(false)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
binding.backbutton.setOnClickListener{requireActivity().onBackPressedDispatcher.onBackPressed()}
|
||||
|
||||
// Clean up temporary files, if any
|
||||
val tempFiles = requireActivity().intent.getStringArrayExtra(PostCreationActivity.TEMP_FILES)
|
||||
tempFiles?.asList()?.forEach {
|
||||
val file = File(binding.root.context.cacheDir, it)
|
||||
model.trackTempFile(file)
|
||||
}
|
||||
|
||||
// Handle back pressed button
|
||||
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
val redraft = requireActivity().intent.getBooleanExtra(PostCreationActivity.POST_REDRAFT, false)
|
||||
if (redraft) {
|
||||
MaterialAlertDialogBuilder(binding.root.context).apply {
|
||||
setMessage(R.string.redraft_dialog_cancel)
|
||||
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
requireActivity().finish()
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
show()
|
||||
}
|
||||
} else {
|
||||
requireActivity().finish()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private val addPhotoResultContract = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val uris = result.data?.extras?.getParcelableArrayList<Uri>(Intent.EXTRA_STREAM)
|
||||
if (result.resultCode == Activity.RESULT_OK && uris != null) {
|
||||
model.setImages(model.addPossibleImages(uris, emptyList()))
|
||||
} else if (result.resultCode != Activity.RESULT_CANCELED) {
|
||||
Toast.makeText(requireActivity(), R.string.add_images_error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun addPhoto(){
|
||||
addPhotoResultContract.launch(
|
||||
Intent(requireActivity(), CameraActivity::class.java)
|
||||
)
|
||||
}
|
||||
|
||||
private fun savePicture(button: View, currentPosition: Int) {
|
||||
val originalUri = model.getPhotoData().value!![currentPosition].imageUri
|
||||
|
||||
val pair = getOutputFile(originalUri)
|
||||
val outputStream: OutputStream = pair.first
|
||||
val path: String = pair.second
|
||||
|
||||
requireActivity().contentResolver.openInputStream(originalUri)!!.use { input ->
|
||||
outputStream.use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
if(path.startsWith("file")) {
|
||||
MediaScannerConnection.scanFile(
|
||||
requireActivity(),
|
||||
arrayOf(path.toUri().toFile().absolutePath),
|
||||
null
|
||||
) { tried_path, uri ->
|
||||
if (uri == null) {
|
||||
Log.e(
|
||||
"NEW IMAGE SCAN FAILED",
|
||||
"Tried to scan $tried_path, but it failed"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Snackbar.make(
|
||||
button, getString(R.string.save_image_success),
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
|
||||
private fun getOutputFile(uri: Uri): Pair<OutputStream, String> {
|
||||
val extension = uri.fileExtension(requireActivity().contentResolver)
|
||||
|
||||
val name = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", Locale.US)
|
||||
.format(System.currentTimeMillis()) + ".$extension"
|
||||
|
||||
val outputStream: OutputStream
|
||||
val path: String
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val resolver: ContentResolver = requireActivity().contentResolver
|
||||
val type = uri.getMimeType(requireActivity().contentResolver)
|
||||
val contentValues = ContentValues()
|
||||
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name)
|
||||
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, type)
|
||||
contentValues.put(
|
||||
MediaStore.MediaColumns.RELATIVE_PATH,
|
||||
Environment.DIRECTORY_PICTURES
|
||||
)
|
||||
val store =
|
||||
if (type.startsWith("video")) MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
else MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
val imageUri: Uri = resolver.insert(store, contentValues)!!
|
||||
path = imageUri.toString()
|
||||
outputStream = resolver.openOutputStream(imageUri)!!
|
||||
} else {
|
||||
val imagesDir = Environment.getExternalStoragePublicDirectory(getString(R.string.app_name))
|
||||
imagesDir.mkdir()
|
||||
val file = File(imagesDir, name)
|
||||
path = Uri.fromFile(file).toString()
|
||||
outputStream = file.outputStream()
|
||||
}
|
||||
return Pair(outputStream, path)
|
||||
}
|
||||
|
||||
|
||||
private fun validatePost(): Boolean {
|
||||
if (model.getPhotoData().value?.none { it.video && it.videoEncodeComplete == false } == true) {
|
||||
// Encoding is done, i.e. none of the items are both a video and not done encoding.
|
||||
// We return true if the post is not empty, false otherwise.
|
||||
return model.getPhotoData().value?.isNotEmpty() == true
|
||||
}
|
||||
// Encoding is not done, show a dialog and return false to indicate validation failed
|
||||
MaterialAlertDialogBuilder(requireActivity()).apply {
|
||||
setMessage(R.string.still_encoding)
|
||||
setNegativeButton(android.R.string.ok) { _, _ -> }
|
||||
}.show()
|
||||
return false
|
||||
}
|
||||
|
||||
private val editResultContract: ActivityResultLauncher<Intent> = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()){
|
||||
result: ActivityResult? ->
|
||||
if (result?.resultCode == Activity.RESULT_OK && result.data != null) {
|
||||
val position: Int = result.data!!.getIntExtra(PICTURE_POSITION, 0)
|
||||
model.modifyAt(position, result.data!!)
|
||||
?: Toast.makeText(requireActivity(), R.string.error_editing, Toast.LENGTH_SHORT).show()
|
||||
} else if(result?.resultCode != Activity.RESULT_CANCELED){
|
||||
Toast.makeText(requireActivity(), R.string.error_editing, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun edit(position: Int) {
|
||||
val intent = Intent(
|
||||
requireActivity(),
|
||||
if (model.getPhotoData().value!![position].video) VideoEditActivity::class.java else PhotoEditActivity::class.java
|
||||
)
|
||||
.putExtra(PICTURE_URI, model.getPhotoData().value!![position].imageUri)
|
||||
.putExtra(PICTURE_POSITION, position)
|
||||
|
||||
editResultContract.launch(intent)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
package org.pixeldroid.app.postCreation
|
||||
|
||||
import android.R.attr.orientation
|
||||
import android.app.Application
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import android.provider.OpenableColumns
|
||||
import android.text.Editable
|
||||
import android.util.Log
|
||||
|
@ -12,38 +11,47 @@ import android.widget.Toast
|
|||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import androidx.lifecycle.*
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.jarsilio.android.scrambler.exceptions.UnsupportedFileFormatException
|
||||
import com.jarsilio.android.scrambler.stripMetadata
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import okhttp3.MultipartBody
|
||||
import org.pixeldroid.app.MainActivity
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.postCreation.photoEdit.PhotoEditActivity
|
||||
import org.pixeldroid.app.postCreation.photoEdit.VideoEditActivity
|
||||
import org.pixeldroid.app.postCreation.photoEdit.VideoEditActivity.RelativeCropPosition
|
||||
import org.pixeldroid.app.utils.PixelDroidApplication
|
||||
import org.pixeldroid.app.postCreation.camera.CameraFragment
|
||||
import org.pixeldroid.app.utils.api.objects.Attachment
|
||||
import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity
|
||||
import org.pixeldroid.app.utils.db.AppDatabase
|
||||
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
|
||||
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
|
||||
import org.pixeldroid.app.utils.fileExtension
|
||||
import org.pixeldroid.app.utils.getMimeType
|
||||
import org.pixeldroid.media_editor.common.PICTURE_URI
|
||||
import org.pixeldroid.media_editor.videoEdit.VideoEditActivity
|
||||
import retrofit2.HttpException
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.net.URI
|
||||
import javax.inject.Inject
|
||||
import kotlin.collections.set
|
||||
import kotlin.math.ceil
|
||||
|
||||
const val TAG = "Post Creation ViewModel"
|
||||
|
||||
// Models the UI state for the PostCreationActivity
|
||||
data class PostCreationActivityUiState(
|
||||
|
@ -52,11 +60,16 @@ data class PostCreationActivityUiState(
|
|||
val addPhotoButtonEnabled: Boolean = true,
|
||||
val editPhotoButtonEnabled: Boolean = true,
|
||||
val removePhotoButtonEnabled: Boolean = true,
|
||||
val postCreationSendButtonEnabled: Boolean = true,
|
||||
val maxEntries: Int?,
|
||||
|
||||
val isCarousel: Boolean = true,
|
||||
|
||||
val postCreationSendButtonEnabled: Boolean = true,
|
||||
|
||||
val newPostDescriptionText: String = "",
|
||||
val nsfw: Boolean = false,
|
||||
|
||||
val chosenAccount: UserDatabaseEntity? = null,
|
||||
|
||||
val uploadProgressBarVisible: Boolean = false,
|
||||
val uploadProgress: Int = 0,
|
||||
|
@ -65,41 +78,85 @@ data class PostCreationActivityUiState(
|
|||
val uploadErrorExplanationText: String = "",
|
||||
val uploadErrorExplanationVisible: Boolean = false,
|
||||
|
||||
val newEncodingJobPosition: Int? = null,
|
||||
val newEncodingJobMuted: Boolean? = null,
|
||||
val newEncodingJobSpeedIndex: Int? = null,
|
||||
val newEncodingJobVideoStart: Float? = null,
|
||||
val newEncodingJobVideoEnd: Float? = null,
|
||||
val newEncodingJobVideoCrop: RelativeCropPosition? = null,
|
||||
val newEncodingJobStabilize: Float? = null,
|
||||
val storyCreation: Boolean,
|
||||
val storyDuration: Int = 10,
|
||||
val storyReplies: Boolean = true,
|
||||
val storyReactions: Boolean = true,
|
||||
)
|
||||
|
||||
class PostCreationViewModel(application: Application, clipdata: ClipData? = null, val instance: InstanceDatabaseEntity? = null) : AndroidViewModel(application) {
|
||||
@Parcelize
|
||||
data class PhotoData(
|
||||
var imageUri: Uri,
|
||||
var size: Long,
|
||||
var uploadId: String? = null,
|
||||
var progress: Int? = null,
|
||||
var imageDescription: String? = null,
|
||||
var video: Boolean,
|
||||
var videoEncodeProgress: Int? = null,
|
||||
var videoEncodeStabilizationFirstPass: Boolean? = null,
|
||||
var videoEncodeComplete: Boolean? = null,
|
||||
var videoEncodeError: Boolean = false,
|
||||
) : Parcelable
|
||||
|
||||
@HiltViewModel
|
||||
class PostCreationViewModel @Inject constructor(
|
||||
private val state: SavedStateHandle,
|
||||
@ApplicationContext private val applicationContext: Context,
|
||||
db: AppDatabase,
|
||||
): ViewModel() {
|
||||
private var storyPhotoDataBackup: MutableList<PhotoData>? = null
|
||||
private val photoData: MutableLiveData<MutableList<PhotoData>> by lazy {
|
||||
MutableLiveData<MutableList<PhotoData>>().also {
|
||||
it.value = clipdata?.let { it1 -> addPossibleImages(it1, mutableListOf()) }
|
||||
//FIXME We should be able to access the Intent action somehow, to determine if there are
|
||||
// 1 or multiple Uris instead of relying on the ClassCastException
|
||||
|
||||
// This should not work like this (reading its source code, get() function should return null
|
||||
// if it's the wrong type but instead throws ClassCastException).
|
||||
// Lucky for us that it does though: we first try to get a single Uri (which we could be
|
||||
// getting from a share of a single picture to the app), when the cast to Uri fails
|
||||
// we try to get a list of Uris instead (casting ourselves from Parcelable as suggested
|
||||
// in get() documentation)
|
||||
val uris = try {
|
||||
val singleUri: Uri? = state[Intent.EXTRA_STREAM]
|
||||
listOfNotNull(singleUri)
|
||||
} catch (e: ClassCastException) {
|
||||
state.get<ArrayList<Parcelable>>(Intent.EXTRA_STREAM)?.map { it as Uri }
|
||||
}
|
||||
|
||||
MutableLiveData<MutableList<PhotoData>>(
|
||||
addPossibleImages(
|
||||
uris,
|
||||
state.get<ArrayList<String>>(PostCreationActivity.PICTURE_DESCRIPTIONS),
|
||||
previousList = mutableListOf()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private val instance = db.instanceDao().getActiveInstance()
|
||||
|
||||
@Inject
|
||||
lateinit var apiHolder: PixelfedAPIHolder
|
||||
|
||||
private val _uiState: MutableStateFlow<PostCreationActivityUiState>
|
||||
|
||||
init {
|
||||
(application as PixelDroidApplication).getAppComponent().inject(this)
|
||||
val sharedPreferences =
|
||||
PreferenceManager.getDefaultSharedPreferences(application)
|
||||
val initialDescription = sharedPreferences.getString("prefill_description", "") ?: ""
|
||||
PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||
val templateDescription = sharedPreferences.getString("prefill_description", "") ?: ""
|
||||
|
||||
_uiState = MutableStateFlow(PostCreationActivityUiState(newPostDescriptionText = initialDescription))
|
||||
val storyCreation: Boolean = state[CameraFragment.CAMERA_ACTIVITY_STORY] ?: false
|
||||
|
||||
_uiState = MutableStateFlow(PostCreationActivityUiState(
|
||||
newPostDescriptionText = state[PostCreationActivity.POST_DESCRIPTION] ?: templateDescription,
|
||||
nsfw = state[PostCreationActivity.POST_NSFW] ?: false,
|
||||
maxEntries = if(storyCreation) 1 else instance?.albumLimit,
|
||||
storyCreation = storyCreation
|
||||
))
|
||||
}
|
||||
|
||||
val uiState: StateFlow<PostCreationActivityUiState> = _uiState
|
||||
|
||||
|
||||
// Map photoData indexes to FFmpeg Session IDs
|
||||
private val sessionMap: MutableMap<Int, Long> = mutableMapOf()
|
||||
private val sessionMap: MutableMap<Uri, Long> = mutableMapOf()
|
||||
// Keep track of temporary files to delete them (avoids filling cache super fast with videos)
|
||||
private val tempFiles: java.util.ArrayList<File> = java.util.ArrayList()
|
||||
|
||||
|
@ -109,47 +166,50 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null
|
|||
}
|
||||
}
|
||||
|
||||
fun encodingStarted() {
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
newEncodingJobPosition = null,
|
||||
newEncodingJobMuted = null,
|
||||
newEncodingJobSpeedIndex = null,
|
||||
newEncodingJobVideoStart = null,
|
||||
newEncodingJobVideoEnd = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only public view on [photoData]
|
||||
*/
|
||||
fun getPhotoData(): LiveData<MutableList<PhotoData>> = photoData
|
||||
|
||||
/**
|
||||
* Will add as many images as possible to [photoData], from the [clipData], and if
|
||||
* ([photoData].size + [clipData].itemCount) > [InstanceDatabaseEntity.albumLimit] then it will only add as many images
|
||||
* Will add as many images as possible to [photoData], from the [uris], and if
|
||||
* ([photoData].size + [uris].size) > uiState.value.maxEntries then it will only add as many images
|
||||
* as are legal (if any) and a dialog will be shown to the user alerting them of this fact.
|
||||
*/
|
||||
fun addPossibleImages(clipData: ClipData, previousList: MutableList<PhotoData>? = photoData.value): MutableList<PhotoData> {
|
||||
fun addPossibleImages(
|
||||
uris: List<Uri>?,
|
||||
descriptions: List<String>?,
|
||||
previousList: MutableList<PhotoData>? = photoData.value,
|
||||
): MutableList<PhotoData> {
|
||||
val dataToAdd: ArrayList<PhotoData> = arrayListOf()
|
||||
var count = clipData.itemCount
|
||||
if(count + (previousList?.size ?: 0) > instance!!.albumLimit){
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(userMessage = getApplication<PixelDroidApplication>().getString(R.string.total_exceeds_album_limit).format(instance.albumLimit))
|
||||
var count = uris?.size ?: 0
|
||||
uiState.value.maxEntries?.let { maxEntries ->
|
||||
if(count + (previousList?.size ?: 0) > maxEntries){
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(userMessage = applicationContext.getString(R.string.total_exceeds_album_limit).format(maxEntries))
|
||||
}
|
||||
count = count.coerceAtMost(maxEntries - (previousList?.size ?: 0))
|
||||
}
|
||||
count = count.coerceAtMost(instance.albumLimit - (previousList?.size ?: 0))
|
||||
}
|
||||
if (count + (previousList?.size ?: 0) >= instance.albumLimit) {
|
||||
// Disable buttons to add more images
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(addPhotoButtonEnabled = false)
|
||||
if (count + (previousList?.size ?: 0) >= maxEntries) {
|
||||
// Disable buttons to add more images
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(addPhotoButtonEnabled = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i in 0 until count) {
|
||||
clipData.getItemAt(i).uri.let {
|
||||
for ((i, uri) in uris.orEmpty().withIndex()) {
|
||||
val sizeAndVideoPair: Pair<Long, Boolean> =
|
||||
getSizeAndVideoValidate(it, (previousList?.size ?: 0) + dataToAdd.size + 1)
|
||||
dataToAdd.add(PhotoData(imageUri = it, size = sizeAndVideoPair.first, video = sizeAndVideoPair.second))
|
||||
getSizeAndVideoValidate(uri, (previousList?.size ?: 0) + dataToAdd.size + 1)
|
||||
dataToAdd.add(
|
||||
PhotoData(
|
||||
imageUri = uri,
|
||||
size = sizeAndVideoPair.first,
|
||||
video = sizeAndVideoPair.second,
|
||||
imageDescription = descriptions?.getOrNull(i)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return previousList?.plus(dataToAdd)?.toMutableList() ?: mutableListOf()
|
||||
}
|
||||
|
||||
|
@ -161,80 +221,175 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null
|
|||
* Returns the size of the file of the Uri, and whether it is a video,
|
||||
* and opens a dialog in case it is too big or in case the file is unsupported.
|
||||
*/
|
||||
fun getSizeAndVideoValidate(uri: Uri, editPosition: Int): Pair<Long, Boolean> {
|
||||
private fun getSizeAndVideoValidate(uri: Uri, editPosition: Int): Pair<Long, Boolean> {
|
||||
val size: Long =
|
||||
if (uri.scheme =="content") {
|
||||
getApplication<PixelDroidApplication>().contentResolver.query(uri, null, null, null, null)
|
||||
applicationContext.contentResolver.query(uri, null, null, null, null)
|
||||
?.use { cursor ->
|
||||
/* Get the column indexes of the data in the Cursor,
|
||||
* move to the first row in the Cursor, get the data,
|
||||
* and display it.
|
||||
*/
|
||||
* move to the first row in the Cursor, get the data,
|
||||
* and display it.
|
||||
*/
|
||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
cursor.moveToFirst()
|
||||
cursor.getLong(sizeIndex)
|
||||
if(sizeIndex >= 0) {
|
||||
cursor.moveToFirst()
|
||||
cursor.getLong(sizeIndex)
|
||||
} else null
|
||||
} ?: 0
|
||||
} else {
|
||||
uri.toFile().length()
|
||||
}
|
||||
|
||||
val sizeInkBytes = ceil(size.toDouble() / 1000).toLong()
|
||||
val type = uri.getMimeType(getApplication<PixelDroidApplication>().contentResolver)
|
||||
val type = uri.getMimeType(applicationContext.contentResolver)
|
||||
val isVideo = type.startsWith("video/")
|
||||
|
||||
if(isVideo && !instance!!.videoEnabled){
|
||||
if (isVideo && !instance!!.videoEnabled) {
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(userMessage = getApplication<PixelDroidApplication>().getString(R.string.video_not_supported))
|
||||
currentUiState.copy(userMessage = applicationContext.getString(R.string.video_not_supported))
|
||||
}
|
||||
}
|
||||
|
||||
if (sizeInkBytes > instance!!.maxPhotoSize || sizeInkBytes > instance.maxVideoSize) {
|
||||
if ((!isVideo && sizeInkBytes > instance!!.maxPhotoSize) || (isVideo && sizeInkBytes > instance!!.maxVideoSize)) {
|
||||
//TODO Offer remedy for too big file: re-compress it
|
||||
val maxSize = if (isVideo) instance.maxVideoSize else instance.maxPhotoSize
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
userMessage = getApplication<PixelDroidApplication>().getString(R.string.size_exceeds_instance_limit, editPosition, sizeInkBytes, maxSize)
|
||||
userMessage = applicationContext.getString(R.string.size_exceeds_instance_limit, editPosition, sizeInkBytes, maxSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
return Pair(size, isVideo)
|
||||
}
|
||||
|
||||
fun isNotEmpty(): Boolean = photoData.value?.isNotEmpty() ?: false
|
||||
|
||||
fun updateDescription(position: Int, description: String) {
|
||||
photoData.value?.getOrNull(position)?.imageDescription = description
|
||||
photoData.value = photoData.value
|
||||
}
|
||||
|
||||
fun resetUploadStatus() {
|
||||
photoData.value = photoData.value?.map { it.copy(uploadId = null, progress = null) }?.toMutableList()
|
||||
}
|
||||
|
||||
fun setVideoEncodeAtPosition(position: Int, progress: Int?, stabilizationFirstPass: Boolean = false) {
|
||||
photoData.value?.set(position, photoData.value!![position].copy(videoEncodeProgress = progress, videoEncodeStabilizationFirstPass = stabilizationFirstPass))
|
||||
photoData.value = photoData.value
|
||||
}
|
||||
|
||||
fun setUriAtPosition(uri: Uri, position: Int) {
|
||||
photoData.value?.set(position, photoData.value!![position].copy(imageUri = uri))
|
||||
photoData.value = photoData.value
|
||||
}
|
||||
|
||||
fun setSizeAtPosition(imageSize: Long, position: Int) {
|
||||
photoData.value?.set(position, photoData.value!![position].copy(size = imageSize))
|
||||
photoData.value = photoData.value
|
||||
}
|
||||
|
||||
fun removeAt(currentPosition: Int) {
|
||||
photoData.value?.removeAt(currentPosition)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
addPhotoButtonEnabled = true
|
||||
)
|
||||
addPhotoButtonEnabled = (photoData.value?.size ?: 0) < (uiState.value.maxEntries ?: 0),
|
||||
)
|
||||
}
|
||||
photoData.value = photoData.value
|
||||
}
|
||||
|
||||
fun modifyAt(position: Int, data: Intent): Unit? {
|
||||
val result: PhotoData = photoData.value?.getOrNull(position)?.run {
|
||||
if (video) {
|
||||
val modified: Boolean = data.getBooleanExtra(VideoEditActivity.MODIFIED, false)
|
||||
if(modified){
|
||||
val videoEncodingArguments: VideoEditActivity.VideoEditArguments? = data.getSerializableExtra(VideoEditActivity.VIDEO_ARGUMENTS_TAG) as? VideoEditActivity.VideoEditArguments
|
||||
|
||||
sessionMap[imageUri]?.let { VideoEditActivity.cancelEncoding(it) }
|
||||
|
||||
videoEncodingArguments?.let {
|
||||
videoEncodeStabilizationFirstPass = it.videoStabilize > 0.01f
|
||||
videoEncodeProgress = 0
|
||||
videoEncodeComplete = false
|
||||
|
||||
VideoEditActivity.startEncoding(imageUri, null, it,
|
||||
context = applicationContext,
|
||||
registerNewFFmpegSession = ::registerNewFFmpegSession,
|
||||
trackTempFile = ::trackTempFile,
|
||||
videoEncodeProgress = ::videoEncodeProgress
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
imageUri = data.getStringExtra(PICTURE_URI)!!.toUri()
|
||||
val (imageSize, imageVideo) = getSizeAndVideoValidate(imageUri, position)
|
||||
size = imageSize
|
||||
video = imageVideo
|
||||
}
|
||||
progress = null
|
||||
uploadId = null
|
||||
this
|
||||
} ?: return null
|
||||
result.let {
|
||||
photoData.value?.set(position, it)
|
||||
photoData.value = photoData.value
|
||||
}
|
||||
return Unit
|
||||
}
|
||||
|
||||
/**
|
||||
* @param originalUri the Uri of the file you sent to be edited
|
||||
* @param progress percentage of (this pass of) encoding that is done
|
||||
* @param firstPass Whether this is the first pass (currently for analysis of video stabilization) or the second (and last) pass.
|
||||
* @param outputVideoPath when not null, it means the encoding is done and the result is saved in this file
|
||||
* @param error is true when there has been an error during encoding.
|
||||
*/
|
||||
private fun videoEncodeProgress(originalUri: Uri, progress: Int, firstPass: Boolean, outputVideoPath: Uri?, error: Boolean){
|
||||
photoData.value?.indexOfFirst { it.imageUri == originalUri }?.let { position ->
|
||||
|
||||
if (outputVideoPath != null) {
|
||||
// If outputVideoPath is not null, it means the video is done and we can change Uris
|
||||
val (size, _) = getSizeAndVideoValidate(outputVideoPath, position)
|
||||
|
||||
photoData.value?.set(position,
|
||||
photoData.value!![position].copy(
|
||||
imageUri = outputVideoPath,
|
||||
videoEncodeProgress = progress,
|
||||
videoEncodeStabilizationFirstPass = firstPass,
|
||||
videoEncodeComplete = true,
|
||||
videoEncodeError = error,
|
||||
size = size,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
photoData.value?.set(position,
|
||||
photoData.value!![position].copy(
|
||||
videoEncodeProgress = progress,
|
||||
videoEncodeStabilizationFirstPass = firstPass,
|
||||
videoEncodeComplete = false,
|
||||
videoEncodeError = error,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Run assignment in main thread
|
||||
viewModelScope.launch {
|
||||
photoData.value = photoData.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun trackTempFile(file: File) {
|
||||
tempFiles.add(file)
|
||||
}
|
||||
|
||||
fun cancelEncode(currentPosition: Int) {
|
||||
sessionMap[photoData.value?.getOrNull(currentPosition)?.imageUri]?.let { VideoEditActivity.cancelEncoding(it) }
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
VideoEditActivity.cancelEncoding()
|
||||
tempFiles.forEach {
|
||||
it.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerNewFFmpegSession(position: Uri, sessionId: Long) {
|
||||
sessionMap[position] = sessionId
|
||||
}
|
||||
|
||||
fun becameCarousel(became: Boolean) {
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
isCarousel = became
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetUploadStatus() {
|
||||
photoData.value = photoData.value?.map { it.copy(uploadId = null, progress = null) }?.toMutableList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the images that are in the [photoData] array.
|
||||
* Keeps track of them in the [PhotoData.progress] (for the upload progress), and the
|
||||
|
@ -245,9 +400,6 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null
|
|||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
postCreationSendButtonEnabled = false,
|
||||
addPhotoButtonEnabled = false,
|
||||
editPhotoButtonEnabled = false,
|
||||
removePhotoButtonEnabled = false,
|
||||
uploadCompletedTextviewVisible = false,
|
||||
uploadErrorVisible = false,
|
||||
uploadProgressBarVisible = true
|
||||
|
@ -255,16 +407,17 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null
|
|||
}
|
||||
|
||||
for (data: PhotoData in getPhotoData().value ?: emptyList()) {
|
||||
val extension = data.imageUri.fileExtension(getApplication<PixelDroidApplication>().contentResolver)
|
||||
val extension = data.imageUri.fileExtension(applicationContext.contentResolver)
|
||||
|
||||
val strippedImage = File.createTempFile("temp_img", ".$extension", getApplication<PixelDroidApplication>().cacheDir)
|
||||
val strippedImage = File.createTempFile("temp_img", ".$extension", applicationContext.cacheDir)
|
||||
|
||||
val imageUri = data.imageUri
|
||||
|
||||
val (strippedOrNot, size) = try {
|
||||
val orientation = ExifInterface(getApplication<PixelDroidApplication>().contentResolver.openInputStream(imageUri)!!).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||
val orientation = ExifInterface(applicationContext.contentResolver.openInputStream(imageUri)!!).getAttributeInt(
|
||||
ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||
|
||||
stripMetadata(imageUri, strippedImage, getApplication<PixelDroidApplication>().contentResolver)
|
||||
stripMetadata(imageUri, strippedImage, applicationContext.contentResolver)
|
||||
|
||||
// Restore EXIF orientation
|
||||
val exifInterface = ExifInterface(strippedImage)
|
||||
|
@ -276,11 +429,11 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null
|
|||
strippedImage.delete()
|
||||
if(imageUri != data.imageUri) File(URI(imageUri.toString())).delete()
|
||||
val imageInputStream = try {
|
||||
getApplication<PixelDroidApplication>().contentResolver.openInputStream(imageUri)!!
|
||||
applicationContext.contentResolver.openInputStream(imageUri)!!
|
||||
} catch (e: FileNotFoundException){
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
userMessage = getApplication<PixelDroidApplication>().getString(R.string.file_not_found,
|
||||
userMessage = applicationContext.getString(R.string.file_not_found,
|
||||
data.imageUri)
|
||||
)
|
||||
}
|
||||
|
@ -292,14 +445,14 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null
|
|||
if(imageUri != data.imageUri) File(URI(imageUri.toString())).delete()
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
userMessage = getApplication<PixelDroidApplication>().getString(R.string.file_not_found,
|
||||
userMessage = applicationContext.getString(R.string.file_not_found,
|
||||
data.imageUri)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val type = data.imageUri.getMimeType(getApplication<PixelDroidApplication>().contentResolver)
|
||||
val type = data.imageUri.getMimeType(applicationContext.contentResolver)
|
||||
val imagePart = ProgressRequestBody(strippedOrNot, size, type)
|
||||
val requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
|
@ -321,23 +474,35 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null
|
|||
|
||||
val description = data.imageDescription?.let { MultipartBody.Part.createFormData("description", it) }
|
||||
|
||||
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
val inter = api.mediaUpload(description, requestBody.parts[0])
|
||||
// Ugly temporary account switching, but it works well enough for now
|
||||
val api = uiState.value.chosenAccount?.let {
|
||||
apiHolder.setToCurrentUser(it)
|
||||
} ?: apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
|
||||
val inter: Observable<Attachment> =
|
||||
//TODO validate that image is correct (?) aspect ratio
|
||||
if (uiState.value.storyCreation) api.storyUpload(requestBody.parts[0])
|
||||
else api.mediaUpload(description, requestBody.parts[0])
|
||||
|
||||
apiHolder.api = null
|
||||
postSub = inter
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ attachment: Attachment ->
|
||||
data.progress = 0
|
||||
data.uploadId = attachment.id!!
|
||||
data.uploadId = if(uiState.value.storyCreation){
|
||||
attachment.media_id!!
|
||||
} else {
|
||||
attachment.id!!
|
||||
}
|
||||
},
|
||||
{ e: Throwable ->
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
uploadErrorVisible = true,
|
||||
uploadErrorExplanationText = if(e is HttpException){
|
||||
getApplication<PixelDroidApplication>().getString(R.string.upload_error, e.code())
|
||||
applicationContext.getString(R.string.upload_error, e.code())
|
||||
} else "",
|
||||
uploadErrorExplanationVisible = e is HttpException,
|
||||
)
|
||||
|
@ -370,6 +535,10 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null
|
|||
|
||||
private fun post() {
|
||||
val description = uiState.value.newPostDescriptionText
|
||||
|
||||
// TODO: investigate why this works but booleans don't
|
||||
val nsfw = if (uiState.value.nsfw) 1 else 0
|
||||
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
postCreationSendButtonEnabled = false
|
||||
|
@ -377,20 +546,36 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null
|
|||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
//Ugly temporary account switching, but it works well enough for now
|
||||
val api = uiState.value.chosenAccount?.let {
|
||||
apiHolder.setToCurrentUser(it)
|
||||
} ?: apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
|
||||
api.postStatus(
|
||||
statusText = description,
|
||||
media_ids = getPhotoData().value!!.mapNotNull { it.uploadId }.toList()
|
||||
)
|
||||
Toast.makeText(getApplication(), getApplication<PixelDroidApplication>().getString(R.string.upload_post_success),
|
||||
if(uiState.value.storyCreation){
|
||||
val canReact = if (uiState.value.storyReactions) "1" else "0"
|
||||
val canReply = if (uiState.value.storyReplies) "1" else "0"
|
||||
|
||||
api.storyPublish(
|
||||
media_id = getPhotoData().value!!.firstNotNullOf { it.uploadId },
|
||||
can_react = canReact,
|
||||
can_reply = canReply,
|
||||
duration = uiState.value.storyDuration
|
||||
)
|
||||
} else {
|
||||
api.postStatus(
|
||||
statusText = description,
|
||||
media_ids = getPhotoData().value!!.mapNotNull { it.uploadId }.toList(),
|
||||
sensitive = nsfw
|
||||
)
|
||||
}
|
||||
Toast.makeText(applicationContext, applicationContext.getString(R.string.upload_post_success),
|
||||
Toast.LENGTH_SHORT).show()
|
||||
val intent = Intent(getApplication(), MainActivity::class.java)
|
||||
val intent = Intent(applicationContext, MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
//TODO make the activity launch this instead (and surrounding toasts too)
|
||||
getApplication<PixelDroidApplication>().startActivity(intent)
|
||||
applicationContext.startActivity(intent)
|
||||
} catch (exception: IOException) {
|
||||
Toast.makeText(getApplication(), getApplication<PixelDroidApplication>().getString(R.string.upload_post_error),
|
||||
Toast.makeText(applicationContext, applicationContext.getString(R.string.upload_post_error),
|
||||
Toast.LENGTH_SHORT).show()
|
||||
Log.e(TAG, exception.toString())
|
||||
_uiState.update { currentUiState ->
|
||||
|
@ -399,7 +584,7 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null
|
|||
)
|
||||
}
|
||||
} catch (exception: HttpException) {
|
||||
Toast.makeText(getApplication(), getApplication<PixelDroidApplication>().getString(R.string.upload_post_failed),
|
||||
Toast.makeText(applicationContext, applicationContext.getString(R.string.upload_post_failed),
|
||||
Toast.LENGTH_SHORT).show()
|
||||
Log.e(TAG, exception.response().toString() + exception.message().toString())
|
||||
_uiState.update { currentUiState ->
|
||||
|
@ -407,98 +592,61 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null
|
|||
postCreationSendButtonEnabled = true
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
apiHolder.api = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun modifyAt(position: Int, data: Intent): Unit? {
|
||||
val result: PhotoData = photoData.value?.getOrNull(position)?.run {
|
||||
if (video) {
|
||||
val modified: Boolean = data.getBooleanExtra(VideoEditActivity.MODIFIED, false)
|
||||
if(modified){
|
||||
val muted: Boolean = data.getBooleanExtra(VideoEditActivity.MUTED, false)
|
||||
val speedIndex: Int = data.getIntExtra(VideoEditActivity.SPEED, 1)
|
||||
val videoStart: Float? = data.getFloatExtra(VideoEditActivity.VIDEO_START, -1f).let {
|
||||
if(it == -1f) null else it
|
||||
}
|
||||
val videoEnd: Float? = data.getFloatExtra(VideoEditActivity.VIDEO_END, -1f).let {
|
||||
if(it == -1f) null else it
|
||||
}
|
||||
|
||||
val videoCrop: RelativeCropPosition = data.getSerializableExtra(VideoEditActivity.VIDEO_CROP) as RelativeCropPosition
|
||||
|
||||
val videoStabilize: Float = data.getFloatExtra(VideoEditActivity.VIDEO_STABILIZE, 0f)
|
||||
|
||||
videoEncodeStabilizationFirstPass = videoStabilize > 0.01f
|
||||
videoEncodeProgress = 0
|
||||
|
||||
sessionMap[position]?.let { FFmpegKit.cancel(it) }
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
newEncodingJobPosition = position,
|
||||
newEncodingJobMuted = muted,
|
||||
newEncodingJobSpeedIndex = speedIndex,
|
||||
newEncodingJobVideoStart = videoStart,
|
||||
newEncodingJobVideoEnd = videoEnd,
|
||||
newEncodingJobVideoCrop = videoCrop,
|
||||
newEncodingJobStabilize = videoStabilize
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
imageUri = data.getStringExtra(PhotoEditActivity.PICTURE_URI)!!.toUri()
|
||||
val (imageSize, imageVideo) = getSizeAndVideoValidate(imageUri, position)
|
||||
size = imageSize
|
||||
video = imageVideo
|
||||
}
|
||||
progress = null
|
||||
uploadId = null
|
||||
this
|
||||
} ?: return null
|
||||
result.let {
|
||||
photoData.value?.set(position, it)
|
||||
photoData.value = photoData.value
|
||||
}
|
||||
return Unit
|
||||
}
|
||||
|
||||
fun newPostDescriptionChanged(text: Editable?) {
|
||||
_uiState.update { it.copy(newPostDescriptionText = text.toString()) }
|
||||
}
|
||||
|
||||
fun trackTempFile(file: File) {
|
||||
tempFiles.add(file)
|
||||
fun updateNSFW(checked: Boolean) { _uiState.update { it.copy(nsfw = checked) } }
|
||||
|
||||
fun chooseAccount(which: UserDatabaseEntity) {
|
||||
_uiState.update { it.copy(chosenAccount = which) }
|
||||
}
|
||||
|
||||
fun cancelEncode(currentPosition: Int) {
|
||||
sessionMap[currentPosition]?.let { FFmpegKit.cancel(it) }
|
||||
}
|
||||
fun storyMode(storyMode: Boolean) {
|
||||
//TODO check ratio of files in story mode? What is acceptable?
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
FFmpegKit.cancel()
|
||||
tempFiles.forEach {
|
||||
it.delete()
|
||||
}
|
||||
val newMaxEntries = if (storyMode) 1 else instance?.albumLimit
|
||||
var newUiState = _uiState.value.copy(
|
||||
storyCreation = storyMode,
|
||||
maxEntries = newMaxEntries,
|
||||
addPhotoButtonEnabled = (photoData.value?.size ?: 0) < (newMaxEntries ?: 0),
|
||||
)
|
||||
|
||||
}
|
||||
// Carousel on if in story mode
|
||||
if (storyMode) newUiState = newUiState.copy(isCarousel = true)
|
||||
|
||||
fun registerNewFFmpegSession(position: Int, sessionId: Long) {
|
||||
sessionMap[position] = sessionId
|
||||
}
|
||||
// If switching to story, and there are too many pictures, keep the first and backup the rest
|
||||
if (storyMode && (photoData.value?.size ?: 0) > 1){
|
||||
storyPhotoDataBackup = photoData.value
|
||||
|
||||
fun becameCarousel(became: Boolean) {
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
isCarousel = became
|
||||
photoData.value = photoData.value?.let { mutableListOf(it.firstOrNull()).filterNotNull().toMutableList() }
|
||||
|
||||
//Show message saying extraneous pictures were removed but can be restored
|
||||
newUiState = newUiState.copy(
|
||||
userMessage = applicationContext.getString(R.string.extraneous_pictures_stories)
|
||||
)
|
||||
}
|
||||
// Restore if backup not null and first value is unchanged
|
||||
else if (storyPhotoDataBackup != null && storyPhotoDataBackup?.firstOrNull() == photoData.value?.firstOrNull()){
|
||||
photoData.value = storyPhotoDataBackup
|
||||
storyPhotoDataBackup = null
|
||||
}
|
||||
_uiState.update { newUiState }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class PostCreationViewModelFactory(val application: Application, val clipdata: ClipData, val instance: InstanceDatabaseEntity) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.getConstructor(Application::class.java, ClipData::class.java, InstanceDatabaseEntity::class.java).newInstance(application, clipdata, instance)
|
||||
fun storyDuration(value: Int) {
|
||||
_uiState.update {
|
||||
it.copy(storyDuration = value)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateStoryReactions(checked: Boolean) { _uiState.update { it.copy(storyReactions = checked) } }
|
||||
|
||||
fun updateStoryReplies(checked: Boolean) { _uiState.update { it.copy(storyReplies = checked) } }
|
||||
}
|
|
@ -0,0 +1,205 @@
|
|||
package org.pixeldroid.app.postCreation
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.launch
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.FragmentPostSubmissionBinding
|
||||
import org.pixeldroid.app.postCreation.camera.CameraFragment
|
||||
import org.pixeldroid.app.utils.BaseFragment
|
||||
import org.pixeldroid.app.utils.bindingLifecycleAware
|
||||
import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity
|
||||
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
|
||||
import org.pixeldroid.app.utils.setSquareImageFromURL
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
class PostSubmissionFragment : BaseFragment() {
|
||||
|
||||
private lateinit var accounts: List<UserDatabaseEntity>
|
||||
private var selectedAccount: Int = -1
|
||||
|
||||
private var user: UserDatabaseEntity? = null
|
||||
private lateinit var instance: InstanceDatabaseEntity
|
||||
|
||||
private var binding: FragmentPostSubmissionBinding by bindingLifecycleAware()
|
||||
private val model: PostCreationViewModel by activityViewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
super.onCreateView(inflater, container, savedInstanceState)
|
||||
|
||||
// Inflate the layout for this fragment
|
||||
binding = FragmentPostSubmissionBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.topBar.setupWithNavController(findNavController())
|
||||
|
||||
user = db.userDao().getActiveUser()
|
||||
accounts = db.userDao().getAll()
|
||||
|
||||
instance = user?.run {
|
||||
db.instanceDao().getInstance(instance_uri)
|
||||
} ?: InstanceDatabaseEntity("", "")
|
||||
|
||||
// Display the values from the view model
|
||||
binding.nsfwSwitch.isChecked = model.uiState.value.nsfw
|
||||
binding.newPostDescriptionInputField.setText(model.uiState.value.newPostDescriptionText)
|
||||
|
||||
if(model.uiState.value.storyCreation){
|
||||
binding.nsfwSwitch.visibility = View.GONE
|
||||
binding.postTextInputLayout.visibility = View.GONE
|
||||
binding.privateTitle.visibility = View.GONE
|
||||
binding.postPreview.visibility = View.GONE
|
||||
|
||||
binding.storyOptions.visibility = View.VISIBLE
|
||||
binding.storyDurationSlider.value = model.uiState.value.storyDuration.toFloat()
|
||||
binding.storyRepliesSwitch.isChecked = model.uiState.value.storyReplies
|
||||
binding.storyReactionsSwitch.isChecked = model.uiState.value.storyReactions
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
model.uiState.collect { uiState ->
|
||||
uiState.userMessage?.let {
|
||||
MaterialAlertDialogBuilder(binding.root.context)
|
||||
.setMessage(it)
|
||||
.setNegativeButton(android.R.string.ok) { _, _ -> }
|
||||
.show()
|
||||
|
||||
// Notify the ViewModel the message is displayed
|
||||
model.userMessageShown()
|
||||
}
|
||||
enableButton(uiState.postCreationSendButtonEnabled)
|
||||
binding.uploadProgressBar.visibility =
|
||||
if (uiState.uploadProgressBarVisible) View.VISIBLE else View.INVISIBLE
|
||||
binding.uploadProgressBar.progress = uiState.uploadProgress
|
||||
binding.uploadCompletedTextview.visibility =
|
||||
if (uiState.uploadCompletedTextviewVisible) View.VISIBLE else View.INVISIBLE
|
||||
binding.uploadError.visibility =
|
||||
if (uiState.uploadErrorVisible) View.VISIBLE else View.INVISIBLE
|
||||
binding.uploadErrorTextExplanation.visibility =
|
||||
if (uiState.uploadErrorExplanationVisible) View.VISIBLE else View.INVISIBLE
|
||||
|
||||
selectedAccount = accounts.indexOf(uiState.chosenAccount)
|
||||
|
||||
binding.uploadErrorTextExplanation.text = uiState.uploadErrorExplanationText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.newPostDescriptionInputField.doAfterTextChanged {
|
||||
model.newPostDescriptionChanged(binding.newPostDescriptionInputField.text)
|
||||
}
|
||||
|
||||
binding.nsfwSwitch.setOnCheckedChangeListener { _, isChecked ->
|
||||
model.updateNSFW(isChecked)
|
||||
}
|
||||
binding.storyRepliesSwitch.setOnCheckedChangeListener { _, isChecked ->
|
||||
model.updateStoryReplies(isChecked)
|
||||
}
|
||||
binding.storyReactionsSwitch.setOnCheckedChangeListener { _, isChecked ->
|
||||
model.updateStoryReactions(isChecked)
|
||||
}
|
||||
|
||||
binding.postTextInputLayout.counterMaxLength = instance.maxStatusChars
|
||||
|
||||
binding.storyDurationSlider.addOnChangeListener { _, value, _ ->
|
||||
// Responds to when slider's value is changed
|
||||
model.storyDuration(value.roundToInt())
|
||||
}
|
||||
|
||||
setSquareImageFromURL(View(requireActivity()), model.getPhotoData().value?.get(0)?.imageUri.toString(), binding.postPreview)
|
||||
|
||||
// Get the description and send the post
|
||||
binding.postSubmissionSendButton.setOnClickListener {
|
||||
if (validatePost()) model.upload()
|
||||
}
|
||||
|
||||
// Button to retry image upload when it fails
|
||||
binding.retryUploadButton.setOnClickListener {
|
||||
model.resetUploadStatus()
|
||||
model.upload()
|
||||
}
|
||||
|
||||
// Handle back pressed button
|
||||
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
findNavController().navigate(R.id.action_postSubmissionFragment_to_postCreationFragment)
|
||||
}
|
||||
})
|
||||
|
||||
binding.topBar.addMenuProvider(object: MenuProvider {
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
// Add menu items here
|
||||
menuInflater.inflate(R.menu.post_submission_account_menu, menu)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
// Handle the menu selection
|
||||
return when (menuItem.itemId) {
|
||||
R.id.action_switch_accounts -> {
|
||||
MaterialAlertDialogBuilder(requireActivity()).apply {
|
||||
setIcon(R.drawable.switch_account)
|
||||
setTitle(R.string.switch_accounts)
|
||||
setSingleChoiceItems(accounts.map { it.username + " (${it.fullHandle})" }.toTypedArray(), selectedAccount) { dialog, which ->
|
||||
if (selectedAccount != which) {
|
||||
model.chooseAccount(accounts[which])
|
||||
}
|
||||
dialog.dismiss()
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
}.show()
|
||||
return true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||
}
|
||||
|
||||
private fun validatePost(): Boolean {
|
||||
binding.postTextInputLayout.run {
|
||||
val content = editText?.length() ?: 0
|
||||
if (content > counterMaxLength) {
|
||||
// error, too many characters
|
||||
error = resources.getQuantityString(R.plurals.description_max_characters, counterMaxLength, counterMaxLength)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun enableButton(enable: Boolean = true){
|
||||
binding.postSubmissionSendButton.isEnabled = enable
|
||||
if(enable){
|
||||
binding.postingProgressBar.visibility = View.GONE
|
||||
binding.postSubmissionSendButton.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.postingProgressBar.visibility = View.VISIBLE
|
||||
binding.postSubmissionSendButton.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -5,47 +5,51 @@ import android.os.Bundle
|
|||
import android.view.MenuItem
|
||||
import org.pixeldroid.app.MainActivity
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.utils.BaseThemedWithBarActivity
|
||||
import org.pixeldroid.app.databinding.ActivityCameraBinding
|
||||
import org.pixeldroid.app.postCreation.camera.CameraFragment.Companion.CAMERA_ACTIVITY
|
||||
import org.pixeldroid.app.postCreation.camera.CameraFragment.Companion.CAMERA_ACTIVITY_STORY
|
||||
import org.pixeldroid.app.utils.BaseActivity
|
||||
|
||||
|
||||
class CameraActivity : BaseThemedWithBarActivity() {
|
||||
class CameraActivity : BaseActivity() {
|
||||
private lateinit var binding: ActivityCameraBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_camera)
|
||||
|
||||
binding = ActivityCameraBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.topBar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setTitle(R.string.add_photo)
|
||||
|
||||
val cameraFragment = CameraFragment()
|
||||
|
||||
val arguments = Bundle()
|
||||
arguments.putBoolean("CameraActivity", true)
|
||||
cameraFragment.arguments = arguments
|
||||
val story: Boolean = intent.getBooleanExtra(CAMERA_ACTIVITY_STORY, false)
|
||||
|
||||
supportFragmentManager.beginTransaction()
|
||||
.add(R.id.camera_activity_fragment, cameraFragment).commit()
|
||||
}
|
||||
}
|
||||
if(story) supportActionBar?.setTitle(R.string.add_story)
|
||||
else supportActionBar?.setTitle(R.string.add_photo)
|
||||
|
||||
/**
|
||||
* Launch without arguments so that it will open the
|
||||
* [org.pixeldroid.app.postCreation.PostCreationActivity] instead of "returning" to a non-existent
|
||||
* [org.pixeldroid.app.postCreation.PostCreationActivity]
|
||||
*/
|
||||
class CameraActivityShortcut : BaseThemedWithBarActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_camera)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setTitle(R.string.new_post_shortcut_long)
|
||||
|
||||
val cameraFragment = CameraFragment()
|
||||
// If this CameraActivity wasn't started from the shortcut,
|
||||
// tell the fragment it's in an activity (so that it sends back the result instead of
|
||||
// starting a new post creation process)
|
||||
if (intent.action != Intent.ACTION_VIEW) {
|
||||
val arguments = Bundle()
|
||||
arguments.putBoolean(CAMERA_ACTIVITY, true)
|
||||
arguments.putBoolean(CAMERA_ACTIVITY_STORY, story)
|
||||
cameraFragment.arguments = arguments
|
||||
} else {
|
||||
supportActionBar?.setTitle(R.string.new_post_shortcut_long)
|
||||
}
|
||||
|
||||
supportFragmentManager.beginTransaction()
|
||||
.add(R.id.camera_activity_fragment, cameraFragment).commit()
|
||||
}
|
||||
|
||||
//Start a new MainActivity when "going back" on this activity
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
// If this CameraActivity wasn't started from the shortcut, behave as usual
|
||||
if (intent.action != Intent.ACTION_VIEW) return super.onOptionsItemSelected(item)
|
||||
|
||||
// Else, start a new MainActivity when "going back" on this activity
|
||||
when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
|
|
|
@ -2,13 +2,13 @@ package org.pixeldroid.app.postCreation.camera
|
|||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.ContentUris
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.util.DisplayMetrics
|
||||
|
@ -17,20 +17,24 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.camera.core.*
|
||||
import androidx.camera.core.AspectRatio
|
||||
import androidx.camera.core.Camera
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageCapture
|
||||
import androidx.camera.core.ImageCapture.Metadata
|
||||
import androidx.camera.core.ImageCaptureException
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.setPadding
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.FragmentCameraBinding
|
||||
import org.pixeldroid.app.postCreation.PostCreationActivity
|
||||
import org.pixeldroid.app.utils.BaseFragment
|
||||
|
@ -63,6 +67,7 @@ class CameraFragment : BaseFragment() {
|
|||
private var camera: Camera? = null
|
||||
|
||||
private var inActivity by Delegates.notNull<Boolean>()
|
||||
private var addToStory by Delegates.notNull<Boolean>()
|
||||
|
||||
private var filePermissionDialogLaunched: Boolean = false
|
||||
|
||||
|
@ -81,7 +86,9 @@ class CameraFragment : BaseFragment() {
|
|||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
inActivity = arguments?.getBoolean("CameraActivity") ?: false
|
||||
super.onCreateView(inflater, container, savedInstanceState)
|
||||
inActivity = arguments?.getBoolean(CAMERA_ACTIVITY) ?: false
|
||||
addToStory = arguments?.getBoolean(CAMERA_ACTIVITY_STORY) ?: false
|
||||
|
||||
binding = FragmentCameraBinding.inflate(layoutInflater)
|
||||
|
||||
|
@ -98,7 +105,7 @@ class CameraFragment : BaseFragment() {
|
|||
thumbnail.setPadding(10)
|
||||
|
||||
// Load thumbnail into circular button using Glide
|
||||
Glide.with(thumbnail)
|
||||
if(activity?.isDestroyed == false) Glide.with(thumbnail)
|
||||
.load(uri)
|
||||
.apply(RequestOptions.circleCropTransform())
|
||||
.into(thumbnail)
|
||||
|
@ -203,14 +210,20 @@ class CameraFragment : BaseFragment() {
|
|||
// Update gallery thumbnail
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
requireContext(),
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) Manifest.permission.READ_MEDIA_IMAGES
|
||||
else Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
updateGalleryThumbnail()
|
||||
}
|
||||
//TODO check if we can get rid of this filePermissionDialogLaunched check (& the variable)
|
||||
else if (!filePermissionDialogLaunched) {
|
||||
// Ask for external storage permission.
|
||||
updateGalleryThumbnailPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
updateGalleryThumbnailPermissionLauncher.launch(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
)
|
||||
}
|
||||
|
||||
cameraLifecycleOwner.resume()
|
||||
|
@ -256,10 +269,8 @@ class CameraFragment : BaseFragment() {
|
|||
) { isGranted: Boolean ->
|
||||
if (isGranted) {
|
||||
updateGalleryThumbnail()
|
||||
} else if(!filePermissionDialogLaunched){
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setMessage(getString(R.string.no_storage_permission))
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->}.show()
|
||||
} else {
|
||||
//TODO should we show the user some message like we did until 75ae26fa4755530794267041de1038f3302ec306 ?
|
||||
filePermissionDialogLaunched = true
|
||||
}
|
||||
}
|
||||
|
@ -272,14 +283,14 @@ class CameraFragment : BaseFragment() {
|
|||
// Find the last picture
|
||||
val projection = arrayOf(
|
||||
MediaStore.Images.ImageColumns._ID,
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) MediaStore.Images.ImageColumns.DATE_TAKEN
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) MediaStore.Images.ImageColumns.DATE_TAKEN
|
||||
else MediaStore.Images.ImageColumns.DATE_MODIFIED,
|
||||
)
|
||||
val cursor = requireContext().contentResolver
|
||||
.query(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null,
|
||||
null,
|
||||
(if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) MediaStore.Images.ImageColumns.DATE_TAKEN
|
||||
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) MediaStore.Images.ImageColumns.DATE_TAKEN
|
||||
else MediaStore.Images.ImageColumns.DATE_MODIFIED) + " DESC"
|
||||
)
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
|
@ -314,7 +325,7 @@ class CameraFragment : BaseFragment() {
|
|||
}
|
||||
|
||||
private fun setupUploadImage() {
|
||||
val videoEnabled: Boolean = db.instanceDao().getInstance(db.userDao().getActiveUser()!!.instance_uri).videoEnabled
|
||||
val videoEnabled: Boolean = db.instanceDao().getActiveInstance().videoEnabled
|
||||
var mimeTypes: Array<String> = arrayOf("image/*")
|
||||
if(videoEnabled) mimeTypes += "video/*"
|
||||
|
||||
|
@ -325,7 +336,8 @@ class CameraFragment : BaseFragment() {
|
|||
putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
|
||||
action = Intent.ACTION_GET_CONTENT
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
// Don't allow multiple for story
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, !addToStory)
|
||||
uploadImageResultContract.launch(
|
||||
Intent.createChooser(this, null)
|
||||
)
|
||||
|
@ -337,11 +349,10 @@ class CameraFragment : BaseFragment() {
|
|||
private val bindCameraPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()
|
||||
) { isGranted: Boolean ->
|
||||
if (isGranted) {
|
||||
binding.cameraPermissionErrorCard.isVisible = false
|
||||
bindCameraUseCases()
|
||||
} else {
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setMessage(R.string.no_camera_permission)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->}.show()
|
||||
binding.cameraPermissionErrorCard.isVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -437,31 +448,22 @@ class CameraFragment : BaseFragment() {
|
|||
|
||||
private fun startAlbumCreation(uris: ArrayList<String>) {
|
||||
|
||||
val intent = Intent(requireActivity(), PostCreationActivity::class.java)
|
||||
.apply {
|
||||
uris.forEach{
|
||||
//Why are we using ClipData here? Because the FLAG_GRANT_READ_URI_PERMISSION
|
||||
//needs to be applied to the URIs, and this flag only applies to the
|
||||
//Intent's data and any URIs specified in its ClipData.
|
||||
if(clipData == null){
|
||||
clipData = ClipData("", emptyArray(), ClipData.Item(it.toUri()))
|
||||
} else {
|
||||
clipData!!.addItem(ClipData.Item(it.toUri()))
|
||||
}
|
||||
}
|
||||
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
val intent = PostCreationActivity.intentForUris(requireContext(), uris.map { it.toUri() })
|
||||
|
||||
if(inActivity){
|
||||
if(inActivity && !addToStory){
|
||||
requireActivity().setResult(Activity.RESULT_OK, intent)
|
||||
requireActivity().finish()
|
||||
} else {
|
||||
if(addToStory){
|
||||
intent.putExtra(CAMERA_ACTIVITY_STORY, addToStory)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CAMERA_ACTIVITY = "CameraActivity"
|
||||
const val CAMERA_ACTIVITY_STORY = "CameraActivityStory"
|
||||
|
||||
private const val TAG = "CameraFragment"
|
||||
private const val RATIO_4_3_VALUE = 4.0 / 3.0
|
||||
|
|
|
@ -32,7 +32,6 @@ class CameraLifecycleOwner : LifecycleOwner {
|
|||
fun stop() {
|
||||
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||
}
|
||||
override fun getLifecycle(): Lifecycle {
|
||||
return lifecycleRegistry
|
||||
}
|
||||
override val lifecycle: Lifecycle
|
||||
get() = lifecycleRegistry
|
||||
}
|
||||
|
|
|
@ -7,5 +7,7 @@ data class CarouselItem constructor(
|
|||
val caption: String? = null,
|
||||
val video: Boolean,
|
||||
var encodeProgress: Int?,
|
||||
var stabilizationFirstPass: Boolean?
|
||||
var stabilizationFirstPass: Boolean?,
|
||||
var encodeComplete: Boolean? = null,
|
||||
var encodeError: Boolean = false,
|
||||
)
|
|
@ -18,13 +18,13 @@ import androidx.recyclerview.widget.*
|
|||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.ImageCarouselBinding
|
||||
import me.relex.circleindicator.CircleIndicator2
|
||||
import org.jetbrains.annotations.NotNull
|
||||
import org.jetbrains.annotations.Nullable
|
||||
|
||||
import org.pixeldroid.common.dpToPx
|
||||
import org.pixeldroid.common.getSnapPosition
|
||||
import org.pixeldroid.common.spToPx
|
||||
|
||||
class ImageCarousel(
|
||||
@NotNull context: Context,
|
||||
@Nullable private var attributeSet: AttributeSet?
|
||||
context: Context,
|
||||
private var attributeSet: AttributeSet?
|
||||
) : ConstraintLayout(context, attributeSet), OnItemClickListener {
|
||||
|
||||
private var adapter: CarouselAdapter? = null
|
||||
|
@ -43,7 +43,6 @@ class ImageCarousel(
|
|||
)
|
||||
|
||||
private lateinit var recyclerView: RecyclerView
|
||||
private lateinit var tvCaption: TextView
|
||||
private var snapHelper: SnapHelper = PagerSnapHelper()
|
||||
|
||||
var indicator: CircleIndicator2? = null
|
||||
|
@ -91,17 +90,7 @@ class ImageCarousel(
|
|||
}
|
||||
|
||||
if (position != RecyclerView.NO_POSITION && field != position) {
|
||||
val thisProgress = data?.getOrNull(position)?.encodeProgress
|
||||
if (thisProgress != null) {
|
||||
binding.encodeInfoCard.visibility = VISIBLE
|
||||
binding.encodeProgress.visibility = VISIBLE
|
||||
binding.encodeInfoText.text = (if(data?.getOrNull(position)?.stabilizationFirstPass == true){
|
||||
context.getString(R.string.analyzing_stabilization)
|
||||
} else context.getString(R.string.encode_progress)).format(thisProgress)
|
||||
binding.encodeProgress.progress = thisProgress
|
||||
} else {
|
||||
binding.encodeInfoCard.visibility = GONE
|
||||
}
|
||||
updateProgress()
|
||||
} else if(position == RecyclerView.NO_POSITION) binding.encodeInfoCard.visibility = GONE
|
||||
|
||||
if (position != RecyclerView.NO_POSITION && recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE) {
|
||||
|
@ -120,7 +109,7 @@ class ImageCarousel(
|
|||
set(value) {
|
||||
field = value
|
||||
|
||||
tvCaption.visibility = if (showCaption) View.VISIBLE else View.GONE
|
||||
binding.tvCaption.visibility = if (showCaption) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
@Dimension(unit = Dimension.PX)
|
||||
|
@ -128,7 +117,7 @@ class ImageCarousel(
|
|||
set(value) {
|
||||
field = value
|
||||
|
||||
tvCaption.setTextSize(TypedValue.COMPLEX_UNIT_PX, captionTextSize.toFloat())
|
||||
binding.tvCaption.setTextSize(TypedValue.COMPLEX_UNIT_PX, captionTextSize.toFloat())
|
||||
}
|
||||
|
||||
var showIndicator = false
|
||||
|
@ -255,18 +244,17 @@ class ImageCarousel(
|
|||
|
||||
if(value){
|
||||
recyclerView.layoutManager = CarouselLinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
|
||||
|
||||
showNavigationButtons = showNavigationButtons
|
||||
|
||||
binding.editMediaDescriptionLayout.visibility = if(editingMediaDescription) VISIBLE else INVISIBLE
|
||||
tvCaption.visibility = if(editingMediaDescription) INVISIBLE else VISIBLE
|
||||
binding.tvCaption.visibility = if(editingMediaDescription || !showCaption) INVISIBLE else VISIBLE
|
||||
} else {
|
||||
recyclerView.layoutManager = GridLayoutManager(context, 3)
|
||||
binding.btnNext.visibility = GONE
|
||||
binding.btnPrevious.visibility = GONE
|
||||
|
||||
binding.editMediaDescriptionLayout.visibility = INVISIBLE
|
||||
tvCaption.visibility = INVISIBLE
|
||||
binding.tvCaption.visibility = INVISIBLE
|
||||
}
|
||||
showIndicator = value
|
||||
|
||||
|
@ -293,8 +281,7 @@ class ImageCarousel(
|
|||
updateDescriptionCallback?.invoke(currentPosition, description)
|
||||
}
|
||||
binding.editMediaDescriptionLayout.visibility = if(value) VISIBLE else INVISIBLE
|
||||
tvCaption.visibility = if(value) INVISIBLE else VISIBLE
|
||||
|
||||
binding.tvCaption.visibility = if(value || !showCaption) INVISIBLE else VISIBLE
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -303,10 +290,10 @@ class ImageCarousel(
|
|||
set(value) {
|
||||
if(!value.isNullOrEmpty()) {
|
||||
field = value
|
||||
tvCaption.text = value
|
||||
binding.tvCaption.text = value
|
||||
} else {
|
||||
field = null
|
||||
tvCaption.text = context.getText(R.string.no_media_description)
|
||||
binding.tvCaption.text = context.getText(R.string.no_media_description)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -331,12 +318,11 @@ class ImageCarousel(
|
|||
binding = ImageCarouselBinding.inflate(LayoutInflater.from(context),this, true)
|
||||
|
||||
recyclerView = binding.recyclerView
|
||||
tvCaption = binding.tvCaption
|
||||
|
||||
recyclerView.setHasFixedSize(true)
|
||||
|
||||
// For marquee effect
|
||||
tvCaption.isSelected = true
|
||||
binding.tvCaption.isSelected = true
|
||||
}
|
||||
|
||||
|
||||
|
@ -438,6 +424,7 @@ class ImageCarousel(
|
|||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
initIndicator()
|
||||
}
|
||||
|
||||
|
||||
|
@ -454,7 +441,7 @@ class ImageCarousel(
|
|||
caption.apply {
|
||||
if(layoutCarousel){
|
||||
binding.editMediaDescriptionLayout.visibility = INVISIBLE
|
||||
tvCaption.visibility = VISIBLE
|
||||
showCaption = true
|
||||
}
|
||||
currentDescription = this
|
||||
}
|
||||
|
@ -485,7 +472,7 @@ class ImageCarousel(
|
|||
}
|
||||
})
|
||||
|
||||
tvCaption.setOnClickListener {
|
||||
binding.tvCaption.setOnClickListener {
|
||||
editingMediaDescription = true
|
||||
}
|
||||
|
||||
|
@ -558,36 +545,42 @@ class ImageCarousel(
|
|||
|
||||
this@ImageCarousel.data = data.toMutableList()
|
||||
|
||||
updateProgress()
|
||||
initOnScrollStateChange()
|
||||
}
|
||||
showNavigationButtons = data.size != 1
|
||||
}
|
||||
|
||||
fun updateProgress(progress: Int?, position: Int, error: Boolean){
|
||||
data?.getOrNull(position)?.encodeProgress = progress
|
||||
if(currentPosition == position) {
|
||||
if (progress == null) {
|
||||
binding.encodeProgress.visibility = GONE
|
||||
if(error){
|
||||
binding.encodeInfoText.setText(R.string.encode_error)
|
||||
binding.encodeInfoText.setCompoundDrawablesWithIntrinsicBounds(ContextCompat.getDrawable(context, R.drawable.error),
|
||||
null, null, null)
|
||||
private fun updateProgress(){
|
||||
|
||||
} else {
|
||||
binding.encodeInfoText.setText(R.string.encode_success)
|
||||
binding.encodeInfoText.setCompoundDrawablesWithIntrinsicBounds(ContextCompat.getDrawable(context, R.drawable.check_circle_24),
|
||||
val currentItem = data?.getOrNull(currentPosition)
|
||||
|
||||
currentItem?.let {
|
||||
if(it.encodeError){
|
||||
binding.encodeInfoCard.visibility = VISIBLE
|
||||
binding.encodeProgress.visibility = GONE
|
||||
binding.encodeInfoText.setText(R.string.encode_error)
|
||||
binding.encodeInfoText.setCompoundDrawablesWithIntrinsicBounds(ContextCompat.getDrawable(context, R.drawable.error),
|
||||
null, null, null)
|
||||
}
|
||||
} else {
|
||||
} else if(it.encodeComplete == true){
|
||||
binding.encodeInfoCard.visibility = VISIBLE
|
||||
binding.encodeProgress.visibility = GONE
|
||||
binding.encodeInfoText.setText(R.string.encode_success)
|
||||
binding.encodeInfoText.setCompoundDrawablesWithIntrinsicBounds(ContextCompat.getDrawable(context, R.drawable.check_circle_24),
|
||||
null, null, null)
|
||||
} else if(it.encodeProgress != null){
|
||||
binding.encodeInfoText.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null)
|
||||
binding.encodeProgress.visibility = VISIBLE
|
||||
binding.encodeInfoCard.visibility = VISIBLE
|
||||
binding.encodeProgress.progress = progress
|
||||
binding.encodeInfoText.text = (if(data?.getOrNull(position)?.stabilizationFirstPass == true){
|
||||
binding.encodeProgress.progress = it.encodeProgress ?: 0
|
||||
binding.encodeInfoText.text = (if(it.stabilizationFirstPass == true){
|
||||
context.getString(R.string.analyzing_stabilization)
|
||||
} else context.getString(R.string.encode_progress)).format(progress)
|
||||
} else context.getString(R.string.encode_progress)).format(it.encodeProgress ?: 0)
|
||||
} else {
|
||||
binding.encodeInfoCard.visibility = GONE
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,52 +0,0 @@
|
|||
package org.pixeldroid.app.postCreation.carousel
|
||||
|
||||
import android.content.Context
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SnapHelper
|
||||
|
||||
|
||||
/**
|
||||
* This method converts device specific pixels to density independent pixels.
|
||||
*/
|
||||
fun Int.pxToDp(context: Context): Int {
|
||||
return (this / (context.resources.displayMetrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* This method converts dp unit to equivalent pixels, depending on device density.
|
||||
*/
|
||||
fun Int.dpToPx(context: Context): Int {
|
||||
return TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
this.toFloat(),
|
||||
context.resources.displayMetrics
|
||||
).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* This method converts sp unit to equivalent pixels, depending on device density.
|
||||
*/
|
||||
fun Int.spToPx(context: Context): Int {
|
||||
return TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_SP,
|
||||
this.toFloat(),
|
||||
context.resources.displayMetrics
|
||||
).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current snap item position of a recyclerView.
|
||||
*
|
||||
* @param layoutManager Target recyclerView
|
||||
* @return Position of the item or RecyclerView.NO_POSITION (-1)
|
||||
*/
|
||||
fun SnapHelper.getSnapPosition(layoutManager: RecyclerView.LayoutManager?): Int {
|
||||
if (layoutManager == null) {
|
||||
return RecyclerView.NO_POSITION
|
||||
}
|
||||
val snapView: View = this.findSnapView(layoutManager) ?: return RecyclerView.NO_POSITION
|
||||
return layoutManager.getPosition(snapView)
|
||||
}
|
|
@ -1,89 +0,0 @@
|
|||
package org.pixeldroid.app.postCreation.photoEdit
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.SeekBar
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.FragmentEditImageBinding
|
||||
|
||||
class EditImageFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
|
||||
|
||||
private var listener: PhotoEditActivity? = null
|
||||
private lateinit var binding: FragmentEditImageBinding
|
||||
|
||||
private var BRIGHTNESS_MAX = 200
|
||||
private var SATURATION_MAX = 20
|
||||
private var CONTRAST_MAX= 30
|
||||
private var BRIGHTNESS_START = BRIGHTNESS_MAX/2
|
||||
private var SATURATION_START = SATURATION_MAX/2
|
||||
private var CONTRAST_START = CONTRAST_MAX/2
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
// Inflate the layout for this fragment
|
||||
binding = FragmentEditImageBinding.inflate(inflater, container, false)
|
||||
|
||||
binding.seekbarBrightness.max = BRIGHTNESS_MAX
|
||||
binding.seekbarBrightness.progress = BRIGHTNESS_START
|
||||
|
||||
binding.seekbarContrast.max = CONTRAST_MAX
|
||||
binding.seekbarContrast.progress = CONTRAST_START
|
||||
|
||||
binding.seekbarSaturation.max = SATURATION_MAX
|
||||
binding.seekbarSaturation.progress = SATURATION_START
|
||||
|
||||
setOnSeekBarChangeListeners(this)
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
private fun setOnSeekBarChangeListeners(listener: EditImageFragment?){
|
||||
binding.seekbarBrightness.setOnSeekBarChangeListener(listener)
|
||||
binding.seekbarContrast.setOnSeekBarChangeListener(listener)
|
||||
binding.seekbarSaturation.setOnSeekBarChangeListener(listener)
|
||||
}
|
||||
|
||||
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
|
||||
var prog = progress
|
||||
|
||||
listener?.let {
|
||||
when(seekBar!!.id) {
|
||||
R.id.seekbar_brightness -> it.onBrightnessChange(progress - 100)
|
||||
R.id.seekbar_saturation -> {
|
||||
prog += 10
|
||||
it.onSaturationChange(.10f * prog)
|
||||
}
|
||||
R.id.seekbar_contrast -> {
|
||||
it.onContrastChange(.10f * prog)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetControl() {
|
||||
// Make sure to ignore seekbar change events, since we don't want to have the reset cause
|
||||
// filter applications due to the onProgressChanged calls
|
||||
setOnSeekBarChangeListeners(null)
|
||||
binding.seekbarBrightness.progress = BRIGHTNESS_START
|
||||
binding.seekbarContrast.progress = CONTRAST_START
|
||||
binding.seekbarSaturation.progress = SATURATION_START
|
||||
setOnSeekBarChangeListeners(this)
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar?) {
|
||||
listener?.onEditStarted()
|
||||
}
|
||||
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar?) {
|
||||
listener?.onEditCompleted()
|
||||
}
|
||||
|
||||
fun setListener(listener: PhotoEditActivity) {
|
||||
this.listener = listener
|
||||
}
|
||||
}
|
|
@ -1,96 +0,0 @@
|
|||
package org.pixeldroid.app.postCreation.photoEdit
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.zomato.photofilters.FilterPack
|
||||
import com.zomato.photofilters.imageprocessors.Filter
|
||||
import com.zomato.photofilters.utils.ThumbnailItem
|
||||
import com.zomato.photofilters.utils.ThumbnailsManager
|
||||
import kotlinx.coroutines.launch
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.FragmentFilterListBinding
|
||||
import org.pixeldroid.app.utils.bitmapFromUri
|
||||
|
||||
class FilterListFragment : Fragment() {
|
||||
|
||||
private lateinit var binding: FragmentFilterListBinding
|
||||
|
||||
private var listener : ((Filter) -> Unit)? = null
|
||||
internal lateinit var adapter: ThumbnailAdapter
|
||||
private lateinit var tbItemList: MutableList<ThumbnailItem>
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
// Inflate the layout for this fragment
|
||||
binding = FragmentFilterListBinding.inflate(inflater, container, false)
|
||||
|
||||
tbItemList = ArrayList()
|
||||
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(activity, LinearLayoutManager.HORIZONTAL, false)
|
||||
|
||||
adapter = ThumbnailAdapter(requireActivity(), tbItemList, this)
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
displayImage()
|
||||
}
|
||||
|
||||
private fun displayImage() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
val tbImage: Bitmap = bitmapFromUri(requireActivity().contentResolver, PhotoEditActivity.imageUri)
|
||||
setupFilter(tbImage)
|
||||
|
||||
tbItemList.addAll(ThumbnailsManager.processThumbs(context))
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupFilter(tbImage: Bitmap?) {
|
||||
ThumbnailsManager.clearThumbs()
|
||||
tbItemList.clear()
|
||||
|
||||
val tbItem = ThumbnailItem()
|
||||
tbItem.image = tbImage
|
||||
tbItem.filter.name = getString(R.string.normal_filter)
|
||||
tbItem.filterName = tbItem.filter.name
|
||||
ThumbnailsManager.addThumb(tbItem)
|
||||
|
||||
val filters = FilterPack.getFilterPack(context)
|
||||
|
||||
for (filter in filters) {
|
||||
val item = ThumbnailItem()
|
||||
item.image = tbImage
|
||||
item.filter = filter
|
||||
item.filterName = filter.name
|
||||
ThumbnailsManager.addThumb(item)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetSelectedFilter(){
|
||||
adapter.resetSelected()
|
||||
}
|
||||
|
||||
fun onFilterSelected(filter: Filter) {
|
||||
listener?.invoke(filter)
|
||||
}
|
||||
|
||||
fun setListener(listFragmentListener: (filter: Filter) -> Unit) {
|
||||
this.listener = listFragmentListener
|
||||
}
|
||||
}
|
|
@ -1,464 +0,0 @@
|
|||
package org.pixeldroid.app.postCreation.photoEdit
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Point
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View.GONE
|
||||
import android.view.View.VISIBLE
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.bumptech.glide.Glide
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.yalantis.ucrop.UCrop
|
||||
import com.zomato.photofilters.imageprocessors.Filter
|
||||
import com.zomato.photofilters.imageprocessors.subfilters.BrightnessSubFilter
|
||||
import com.zomato.photofilters.imageprocessors.subfilters.ContrastSubFilter
|
||||
import com.zomato.photofilters.imageprocessors.subfilters.SaturationSubfilter
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.ActivityPhotoEditBinding
|
||||
import org.pixeldroid.app.postCreation.PostCreationActivity
|
||||
import org.pixeldroid.app.utils.BaseThemedWithBarActivity
|
||||
import org.pixeldroid.app.utils.bitmapFromUri
|
||||
import org.pixeldroid.app.utils.getColorFromAttr
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors.newSingleThreadExecutor
|
||||
import java.util.concurrent.Future
|
||||
|
||||
// This is an arbitrary number we are using to keep track of the permission
|
||||
// request. Where an app has multiple context for requesting permission,
|
||||
// this can help differentiate the different contexts.
|
||||
private const val REQUEST_CODE_PERMISSIONS_SEND_PHOTO = 7
|
||||
private val REQUIRED_PERMISSIONS = arrayOf(
|
||||
android.Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||
android.Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
)
|
||||
|
||||
class PhotoEditActivity : BaseThemedWithBarActivity() {
|
||||
|
||||
var saving: Boolean = false
|
||||
private val BITMAP_CONFIG = Bitmap.Config.ARGB_8888
|
||||
private val BRIGHTNESS_START = 0
|
||||
private val SATURATION_START = 1.0f
|
||||
private val CONTRAST_START = 1.0f
|
||||
|
||||
private var originalImage: Bitmap? = null
|
||||
private var compressedImage: Bitmap? = null
|
||||
private var compressedOriginalImage: Bitmap? = null
|
||||
private lateinit var filteredImage: Bitmap
|
||||
|
||||
private var actualFilter: Filter? = null
|
||||
|
||||
private lateinit var filterListFragment: FilterListFragment
|
||||
private lateinit var editImageFragment: EditImageFragment
|
||||
|
||||
private var picturePosition: Int? = null
|
||||
|
||||
private var brightnessFinal = BRIGHTNESS_START
|
||||
private var saturationFinal = SATURATION_START
|
||||
private var contrastFinal = CONTRAST_START
|
||||
|
||||
init {
|
||||
System.loadLibrary("NativeImageProcessor")
|
||||
}
|
||||
|
||||
companion object{
|
||||
internal const val PICTURE_URI = "picture_uri"
|
||||
internal const val PICTURE_POSITION = "picture_position"
|
||||
|
||||
private var executor: ExecutorService = newSingleThreadExecutor()
|
||||
private var future: Future<*>? = null
|
||||
|
||||
private var saveExecutor: ExecutorService = newSingleThreadExecutor()
|
||||
private var saveFuture: Future<*>? = null
|
||||
|
||||
private var initialUri: Uri? = null
|
||||
internal var imageUri: Uri? = null
|
||||
}
|
||||
|
||||
private lateinit var binding: ActivityPhotoEditBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityPhotoEditBinding.inflate(layoutInflater)
|
||||
|
||||
setContentView(binding.root)
|
||||
|
||||
|
||||
supportActionBar?.setTitle(R.string.toolbar_title_edit)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setHomeButtonEnabled(true)
|
||||
|
||||
initialUri = intent.getParcelableExtra(PICTURE_URI)
|
||||
picturePosition = intent.getIntExtra(PICTURE_POSITION, 0)
|
||||
imageUri = initialUri
|
||||
|
||||
// Crop button on-click listener
|
||||
binding.cropImageButton.setOnClickListener {
|
||||
startCrop()
|
||||
}
|
||||
|
||||
loadImage()
|
||||
|
||||
setupViewPager(binding.viewPager)
|
||||
}
|
||||
|
||||
private fun loadImage() {
|
||||
originalImage = bitmapFromUri(contentResolver, imageUri)
|
||||
|
||||
compressedImage = resizeImage(originalImage!!)
|
||||
compressedOriginalImage = compressedImage!!.copy(BITMAP_CONFIG, true)
|
||||
filteredImage = compressedImage!!.copy(BITMAP_CONFIG, true)
|
||||
Glide.with(this).load(compressedImage).into(binding.imagePreview)
|
||||
}
|
||||
|
||||
private fun resizeImage(image: Bitmap): Bitmap {
|
||||
val display = windowManager.defaultDisplay
|
||||
val size = Point()
|
||||
display.getSize(size)
|
||||
|
||||
val newY = size.y * 0.7
|
||||
val scale = newY / image.height
|
||||
return Bitmap.createScaledBitmap(image, (image.width * scale).toInt(), newY.toInt(), true)
|
||||
}
|
||||
|
||||
private fun setupViewPager(viewPager: ViewPager2) {
|
||||
filterListFragment = FilterListFragment()
|
||||
filterListFragment.setListener(::onFilterSelected)
|
||||
|
||||
editImageFragment = EditImageFragment()
|
||||
editImageFragment.setListener(this)
|
||||
|
||||
val tabs: List<() -> Fragment> = listOf({ filterListFragment }, { editImageFragment })
|
||||
|
||||
// Keep both tabs loaded at all times because values are needed there
|
||||
viewPager.offscreenPageLimit = 1
|
||||
|
||||
//Disable swiping in viewpager
|
||||
viewPager.isUserInputEnabled = false
|
||||
|
||||
viewPager.adapter = object : FragmentStateAdapter(this) {
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
return tabs[position]()
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return tabs.size
|
||||
}
|
||||
}
|
||||
TabLayoutMediator(binding.tabs, viewPager) { tab, position ->
|
||||
tab.setText(when(position) {
|
||||
0 -> R.string.tab_filters
|
||||
else -> R.string.edit
|
||||
})
|
||||
}.attach()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.edit_menu, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
saving = false
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (noEdits()) super.onBackPressed()
|
||||
else {
|
||||
val builder = AlertDialog.Builder(this)
|
||||
builder.apply {
|
||||
setMessage(R.string.save_before_returning)
|
||||
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
saveImageToGallery()
|
||||
}
|
||||
setNegativeButton(R.string.no_cancel_edit) { _, _ ->
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
// Create the AlertDialog
|
||||
builder.show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
|
||||
when(item.itemId) {
|
||||
android.R.id.home -> onBackPressed()
|
||||
R.id.action_save -> {
|
||||
saveImageToGallery()
|
||||
}
|
||||
R.id.action_reset -> {
|
||||
resetControls()
|
||||
actualFilter = null
|
||||
imageUri = initialUri
|
||||
loadImage()
|
||||
filterListFragment.resetSelectedFilter()
|
||||
}
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
fun onFilterSelected(filter: Filter) {
|
||||
filteredImage = compressedOriginalImage!!.copy(BITMAP_CONFIG, true)
|
||||
binding.imagePreview.setImageBitmap(filter.processFilter(filteredImage))
|
||||
compressedImage = filteredImage.copy(BITMAP_CONFIG, true)
|
||||
actualFilter = filter
|
||||
resetControls()
|
||||
}
|
||||
|
||||
private fun resetControls() {
|
||||
brightnessFinal = BRIGHTNESS_START
|
||||
saturationFinal = SATURATION_START
|
||||
contrastFinal = CONTRAST_START
|
||||
|
||||
editImageFragment.resetControl()
|
||||
}
|
||||
|
||||
|
||||
private fun applyFilterAndShowImage(filter: Filter, image: Bitmap?) {
|
||||
future?.cancel(true)
|
||||
future = executor.submit {
|
||||
val bitmap = filter.processFilter(image!!.copy(BITMAP_CONFIG, true))
|
||||
binding.imagePreview.post {
|
||||
binding.imagePreview.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onBrightnessChange(brightness: Int) {
|
||||
brightnessFinal = brightness
|
||||
val myFilter = Filter()
|
||||
myFilter.addEditFilters(brightness, saturationFinal, contrastFinal)
|
||||
applyFilterAndShowImage(myFilter, filteredImage)
|
||||
}
|
||||
|
||||
fun onSaturationChange(saturation: Float) {
|
||||
saturationFinal = saturation
|
||||
val myFilter = Filter()
|
||||
myFilter.addEditFilters(brightnessFinal, saturation, contrastFinal)
|
||||
applyFilterAndShowImage(myFilter, filteredImage)
|
||||
}
|
||||
|
||||
fun onContrastChange(contrast: Float) {
|
||||
contrastFinal = contrast
|
||||
val myFilter = Filter()
|
||||
myFilter.addEditFilters(brightnessFinal, saturationFinal, contrast)
|
||||
applyFilterAndShowImage(myFilter, filteredImage)
|
||||
}
|
||||
|
||||
private fun Filter.addEditFilters(br: Int, sa: Float, co: Float): Filter {
|
||||
addSubFilter(BrightnessSubFilter(br))
|
||||
addSubFilter(ContrastSubFilter(co))
|
||||
addSubFilter(SaturationSubfilter(sa))
|
||||
return this
|
||||
}
|
||||
|
||||
fun onEditStarted() {
|
||||
}
|
||||
|
||||
fun onEditCompleted() {
|
||||
val myFilter = Filter()
|
||||
myFilter.addEditFilters(brightnessFinal, saturationFinal, contrastFinal)
|
||||
val bitmap = filteredImage.copy(BITMAP_CONFIG, true)
|
||||
|
||||
compressedImage = myFilter.processFilter(bitmap)
|
||||
}
|
||||
|
||||
|
||||
private fun startCrop() {
|
||||
val file = File.createTempFile("temp_crop_img", ".png", cacheDir)
|
||||
|
||||
val options: UCrop.Options = UCrop.Options().apply {
|
||||
setStatusBarColor(this@PhotoEditActivity.getColorFromAttr(R.attr.colorPrimaryDark))
|
||||
setToolbarWidgetColor(this@PhotoEditActivity.getColorFromAttr(R.attr.colorOnSurface))
|
||||
setToolbarColor(this@PhotoEditActivity.getColorFromAttr(R.attr.colorSurface))
|
||||
setActiveControlsWidgetColor(this@PhotoEditActivity.getColorFromAttr(R.attr.colorPrimary))
|
||||
setFreeStyleCropEnabled(true)
|
||||
}
|
||||
val uCrop: UCrop = UCrop.of(initialUri!!, Uri.fromFile(file)).withOptions(options)
|
||||
uCrop.start(this)
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if(resultCode == Activity.RESULT_OK) {
|
||||
if (requestCode == UCrop.RESULT_ERROR) {
|
||||
handleCropError(data)
|
||||
} else {
|
||||
handleCropResult(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetFilteredImage(){
|
||||
val newBr = if(brightnessFinal != 0) BRIGHTNESS_START/brightnessFinal else 0
|
||||
val newSa = if(saturationFinal != 0.0f) SATURATION_START/saturationFinal else 0.0f
|
||||
val newCo = if(contrastFinal != 0.0f) CONTRAST_START/contrastFinal else 0.0f
|
||||
val myFilter = Filter().addEditFilters(newBr, newSa, newCo)
|
||||
|
||||
filteredImage = myFilter.processFilter(filteredImage)
|
||||
}
|
||||
|
||||
private fun handleCropResult(data: Intent?) {
|
||||
val resultCrop: Uri? = UCrop.getOutput(data!!)
|
||||
if(resultCrop != null) {
|
||||
imageUri = resultCrop
|
||||
binding.imagePreview.setImageURI(resultCrop)
|
||||
val bitmap = (binding.imagePreview.drawable as BitmapDrawable).bitmap
|
||||
originalImage = bitmap.copy(Bitmap.Config.ARGB_8888, true)
|
||||
compressedImage = resizeImage(originalImage!!.copy(BITMAP_CONFIG, true))
|
||||
compressedOriginalImage = compressedImage!!.copy(BITMAP_CONFIG, true)
|
||||
filteredImage = compressedImage!!.copy(BITMAP_CONFIG, true)
|
||||
resetFilteredImage()
|
||||
} else {
|
||||
Toast.makeText(this, R.string.crop_result_error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCropError(data: Intent?) {
|
||||
val resultError = UCrop.getError(data!!)
|
||||
if(resultError != null) {
|
||||
Toast.makeText(this, "" + resultError, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(this, R.string.crop_result_error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if(grantResults.size > 1
|
||||
&& grantResults[0] == PackageManager.PERMISSION_GRANTED
|
||||
&& grantResults[1] == PackageManager.PERMISSION_GRANTED) {
|
||||
// permission was granted
|
||||
permissionsGrantedToSave()
|
||||
} else {
|
||||
Snackbar.make(binding.root, getString(R.string.permission_denied),
|
||||
Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyFinalFilters(image: Bitmap?): Bitmap {
|
||||
val editFilter = Filter().addEditFilters(brightnessFinal, saturationFinal, contrastFinal)
|
||||
|
||||
var finalImage = editFilter.processFilter(image!!.copy(BITMAP_CONFIG, true))
|
||||
if (actualFilter!=null) finalImage = actualFilter!!.processFilter(finalImage)
|
||||
return finalImage
|
||||
}
|
||||
|
||||
private fun sendBackImage(file: String) {
|
||||
val intent = Intent(this, PostCreationActivity::class.java)
|
||||
.apply {
|
||||
putExtra(PICTURE_URI, file)
|
||||
putExtra(PICTURE_POSITION, picturePosition)
|
||||
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
||||
}
|
||||
|
||||
setResult(Activity.RESULT_OK, intent)
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun saveImageToGallery() {
|
||||
// runtime permission and process
|
||||
if (!allPermissionsGranted()) {
|
||||
ActivityCompat.requestPermissions(
|
||||
this,
|
||||
REQUIRED_PERMISSIONS,
|
||||
REQUEST_CODE_PERMISSIONS_SEND_PHOTO
|
||||
)
|
||||
} else {
|
||||
permissionsGrantedToSave()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all permission specified in the manifest have been granted
|
||||
*/
|
||||
private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
|
||||
ContextCompat.checkSelfPermission(
|
||||
applicationContext, it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
|
||||
private fun OutputStream.writeBitmap(bitmap: Bitmap) {
|
||||
use { out ->
|
||||
//(quality is ignored for PNG)
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 85, out)
|
||||
out.flush()
|
||||
}
|
||||
}
|
||||
|
||||
private fun noEdits(): Boolean =
|
||||
brightnessFinal == BRIGHTNESS_START
|
||||
&& contrastFinal == CONTRAST_START
|
||||
&& saturationFinal == SATURATION_START
|
||||
&& actualFilter?.let { it.name == getString(R.string.normal_filter)} ?: true
|
||||
|
||||
private fun permissionsGrantedToSave() {
|
||||
if (saving) {
|
||||
val builder = AlertDialog.Builder(this)
|
||||
builder.apply {
|
||||
setMessage(R.string.busy_dialog_text)
|
||||
setNegativeButton(R.string.busy_dialog_ok_button) { _, _ -> }
|
||||
}
|
||||
// Create the AlertDialog
|
||||
builder.show()
|
||||
return
|
||||
}
|
||||
saving = true
|
||||
binding.progressBarSaveFile.visibility = VISIBLE
|
||||
saveFuture = saveExecutor.submit {
|
||||
try {
|
||||
val path: String
|
||||
if(!noEdits()) {
|
||||
// Save modified image in cache
|
||||
val tempFile = File.createTempFile("temp_edit_img", ".png", cacheDir)
|
||||
path = Uri.fromFile(tempFile).toString()
|
||||
tempFile.outputStream().writeBitmap(applyFinalFilters(originalImage))
|
||||
}
|
||||
else {
|
||||
path = imageUri.toString()
|
||||
}
|
||||
|
||||
if(saving) {
|
||||
this.runOnUiThread {
|
||||
sendBackImage(path)
|
||||
binding.progressBarSaveFile.visibility = GONE
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
this.runOnUiThread {
|
||||
Snackbar.make(
|
||||
binding.root, getString(R.string.save_image_failed),
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
binding.progressBarSaveFile.visibility = GONE
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
package org.pixeldroid.app.postCreation.photoEdit
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.zomato.photofilters.utils.ThumbnailItem
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.ThumbnailListItemBinding
|
||||
import org.pixeldroid.app.utils.getColorFromAttr
|
||||
|
||||
class ThumbnailAdapter (private val context: Context,
|
||||
private val tbItemList: List<ThumbnailItem>,
|
||||
private val listener: FilterListFragment): RecyclerView.Adapter<ThumbnailAdapter.MyViewHolder>() {
|
||||
|
||||
private var selectedIndex = 0
|
||||
|
||||
fun resetSelected(){
|
||||
selectedIndex = 0
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
|
||||
val itemBinding = ThumbnailListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return MyViewHolder(itemBinding)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return tbItemList.size
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
|
||||
val tbItem = tbItemList[position]
|
||||
holder.thumbnail.setImageBitmap(tbItem.image)
|
||||
holder.thumbnail.setOnClickListener {
|
||||
listener.onFilterSelected(tbItem.filter)
|
||||
selectedIndex = holder.bindingAdapterPosition
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
holder.filterName.text = tbItem.filterName
|
||||
|
||||
if(selectedIndex == position)
|
||||
holder.filterName.setTextColor(context.getColorFromAttr(R.attr.colorPrimary))
|
||||
else
|
||||
holder.filterName.setTextColor(context.getColorFromAttr(R.attr.colorOnBackground))
|
||||
}
|
||||
|
||||
class MyViewHolder(itemBinding: ThumbnailListItemBinding): RecyclerView.ViewHolder(itemBinding.root) {
|
||||
var thumbnail: ImageView = itemBinding.thumbnail
|
||||
var filterName: TextView = itemBinding.filterName
|
||||
}
|
||||
}
|
|
@ -1,465 +0,0 @@
|
|||
package org.pixeldroid.app.postCreation.photoEdit
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.graphics.Rect
|
||||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.format.DateUtils
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.HandlerCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.media.AudioAttributesCompat
|
||||
import androidx.media2.common.MediaMetadata
|
||||
import androidx.media2.common.UriMediaItem
|
||||
import androidx.media2.player.MediaPlayer
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import com.arthenica.ffmpegkit.FFprobeKit
|
||||
import com.arthenica.ffmpegkit.MediaInformation
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import com.bumptech.glide.Glide
|
||||
import com.google.android.material.slider.RangeSlider
|
||||
import com.google.android.material.slider.Slider
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.ActivityVideoEditBinding
|
||||
import org.pixeldroid.app.postCreation.PostCreationActivity
|
||||
import org.pixeldroid.app.postCreation.carousel.dpToPx
|
||||
import org.pixeldroid.app.utils.BaseThemedWithBarActivity
|
||||
import org.pixeldroid.app.utils.ffmpegCompliantUri
|
||||
import java.io.File
|
||||
import java.io.Serializable
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
class VideoEditActivity : BaseThemedWithBarActivity() {
|
||||
|
||||
data class RelativeCropPosition(
|
||||
// Width of the selected part of the video, relative to the width of the video
|
||||
val relativeWidth: Float = 1f,
|
||||
// Height of the selected part of the video, relative to the height of the video
|
||||
val relativeHeight: Float = 1f,
|
||||
// Distance of left corner of selected part, relative to the width of the video
|
||||
val relativeX: Float = 0f,
|
||||
// Distance of top of selected part, relative to the height of the video
|
||||
val relativeY: Float = 0f,
|
||||
): Serializable {
|
||||
fun notCropped(): Boolean =
|
||||
(relativeWidth - 1f).absoluteValue < 0.001f
|
||||
&& (relativeHeight - 1f).absoluteValue < 0.001f
|
||||
&& relativeX.absoluteValue < 0.001f
|
||||
&& relativeY.absoluteValue < 0.001f
|
||||
|
||||
}
|
||||
|
||||
private lateinit var mediaPlayer: MediaPlayer
|
||||
private var videoPosition: Int = -1
|
||||
|
||||
private var cropRelativeDimensions: RelativeCropPosition = RelativeCropPosition()
|
||||
|
||||
private var stabilization: Float = 0f
|
||||
set(value){
|
||||
field = value
|
||||
if(value > 0.01f && value <= 100f){
|
||||
// Stabilization requested, show UI
|
||||
binding.stabilisationSaved.isVisible = true
|
||||
val typedValue = TypedValue()
|
||||
val color: Int = if (binding.stabilizer.context.theme
|
||||
.resolveAttribute(R.attr.colorSecondary, typedValue, true)
|
||||
) typedValue.data else Color.TRANSPARENT
|
||||
|
||||
binding.stabilizer.drawable.setTint(color)
|
||||
}
|
||||
else {
|
||||
binding.stabilisationSaved.isVisible = false
|
||||
binding.stabilizer.drawable.setTintList(null)
|
||||
}
|
||||
}
|
||||
|
||||
private var speed: Int = 1
|
||||
set(value) {
|
||||
field = value
|
||||
|
||||
mediaPlayer.playbackSpeed = speedChoices[value].toFloat()
|
||||
|
||||
if(speed != 1) binding.muter.callOnClick()
|
||||
}
|
||||
|
||||
private lateinit var binding: ActivityVideoEditBinding
|
||||
// Map photoData indexes to FFmpeg Session IDs
|
||||
private val sessionList: ArrayList<Long> = arrayListOf()
|
||||
private val tempFiles: ArrayList<File> = ArrayList()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityVideoEditBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
supportActionBar?.setTitle(R.string.toolbar_title_edit)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setHomeButtonEnabled(true)
|
||||
|
||||
|
||||
binding.videoRangeSeekBar.setCustomThumbDrawablesForValues(R.drawable.thumb_left,R.drawable.double_circle,R.drawable.thumb_right)
|
||||
binding.videoRangeSeekBar.thumbRadius = 20.dpToPx(this)
|
||||
|
||||
|
||||
val resultHandler: Handler = HandlerCompat.createAsync(Looper.getMainLooper())
|
||||
|
||||
val uri = intent.getParcelableExtra<Uri>(PhotoEditActivity.PICTURE_URI)!!
|
||||
|
||||
videoPosition = intent.getIntExtra(PhotoEditActivity.PICTURE_POSITION, -1)
|
||||
|
||||
val inputVideoPath = ffmpegCompliantUri(uri)
|
||||
val mediaInformation: MediaInformation? = FFprobeKit.getMediaInformation(inputVideoPath).mediaInformation
|
||||
|
||||
//Duration in seconds, or null
|
||||
val duration: Float? = mediaInformation?.duration?.toFloatOrNull()
|
||||
|
||||
binding.videoRangeSeekBar.valueFrom = 0f
|
||||
binding.videoRangeSeekBar.valueTo = duration ?: 100f
|
||||
binding.videoRangeSeekBar.values = listOf(0f,(duration?: 100f) / 2, duration ?: 100f)
|
||||
|
||||
|
||||
val mediaItem: UriMediaItem = UriMediaItem.Builder(uri).build()
|
||||
mediaItem.metadata = MediaMetadata.Builder()
|
||||
.putString(MediaMetadata.METADATA_KEY_TITLE, "")
|
||||
.build()
|
||||
|
||||
mediaPlayer = MediaPlayer(this)
|
||||
mediaPlayer.setMediaItem(mediaItem)
|
||||
|
||||
//binding.videoView.mediaControlView?.setMediaController()
|
||||
|
||||
// Configure audio
|
||||
mediaPlayer.setAudioAttributes(AudioAttributesCompat.Builder()
|
||||
.setLegacyStreamType(AudioManager.STREAM_MUSIC)
|
||||
.setUsage(AudioAttributesCompat.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributesCompat.CONTENT_TYPE_MOVIE)
|
||||
.build()
|
||||
)
|
||||
|
||||
findViewById<FrameLayout?>(R.id.progress_bar)?.visibility = View.GONE
|
||||
|
||||
mediaPlayer.prepare()
|
||||
|
||||
|
||||
binding.muter.setOnClickListener {
|
||||
if(!binding.muter.isSelected) mediaPlayer.playerVolume = 0f
|
||||
else {
|
||||
mediaPlayer.playerVolume = 1f
|
||||
speed = 1
|
||||
}
|
||||
binding.muter.isSelected = !binding.muter.isSelected
|
||||
}
|
||||
|
||||
binding.cropper.setOnClickListener {
|
||||
showCropInterface(show = true, uri = uri)
|
||||
}
|
||||
|
||||
binding.saveCropButton.setOnClickListener {
|
||||
// This is the rectangle selected by the crop
|
||||
val cropRect = binding.cropImageView.cropWindowRect
|
||||
|
||||
// This is the rectangle of the whole image
|
||||
val fullImageRect: Rect = binding.cropImageView.getInitialCropWindowRect()
|
||||
|
||||
// x, y are coordinates of top left, in the ImageView
|
||||
val x = cropRect.left - fullImageRect.left
|
||||
val y = cropRect.top - fullImageRect.top
|
||||
|
||||
// width and height selected by the crop
|
||||
val width = cropRect.width()
|
||||
val height = cropRect.height()
|
||||
|
||||
// To avoid having to calculate the dimensions of the video here, we pass
|
||||
// relative width, height and x, y back to be treated in FFmpeg
|
||||
cropRelativeDimensions = RelativeCropPosition(
|
||||
relativeWidth = width/fullImageRect.width(),
|
||||
relativeHeight = height/fullImageRect.height(),
|
||||
relativeX = x/fullImageRect.width(),
|
||||
relativeY = y/fullImageRect.height()
|
||||
)
|
||||
|
||||
// If a crop was saved, change the color of the crop button to give a visual indication
|
||||
if(!cropRelativeDimensions.notCropped()){
|
||||
val typedValue = TypedValue()
|
||||
val color: Int = if (binding.checkMarkCropped.context.theme
|
||||
.resolveAttribute(R.attr.colorSecondary, typedValue, true)
|
||||
) typedValue.data else Color.TRANSPARENT
|
||||
|
||||
binding.cropper.drawable.setTint(color)
|
||||
} else {
|
||||
// Else reset the tint
|
||||
binding.cropper.drawable.setTintList(null)
|
||||
}
|
||||
|
||||
showCropInterface(show = false)
|
||||
}
|
||||
|
||||
binding.videoView.setPlayer(mediaPlayer)
|
||||
|
||||
mediaPlayer.seekTo((binding.videoRangeSeekBar.values[1]*1000).toLong())
|
||||
|
||||
object : Runnable {
|
||||
override fun run() {
|
||||
val getCurrent = mediaPlayer.currentPosition / 1000f
|
||||
if(getCurrent >= binding.videoRangeSeekBar.values[0] && getCurrent <= binding.videoRangeSeekBar.values[2] ) {
|
||||
binding.videoRangeSeekBar.values = listOf(binding.videoRangeSeekBar.values[0],getCurrent, binding.videoRangeSeekBar.values[2])
|
||||
}
|
||||
Handler(Looper.getMainLooper()).postDelayed(this, 1000)
|
||||
}
|
||||
}.run()
|
||||
|
||||
binding.videoRangeSeekBar.addOnChangeListener { rangeSlider: RangeSlider, value, fromUser ->
|
||||
// Responds to when the middle slider's value is changed
|
||||
if(fromUser && value != rangeSlider.values[0] && value != rangeSlider.values[2]) {
|
||||
mediaPlayer.seekTo((rangeSlider.values[1]*1000).toLong())
|
||||
}
|
||||
}
|
||||
|
||||
binding.videoRangeSeekBar.setLabelFormatter { value: Float ->
|
||||
DateUtils.formatElapsedTime(value.toLong())
|
||||
}
|
||||
|
||||
|
||||
|
||||
binding.speeder.setOnClickListener {
|
||||
AlertDialog.Builder(this).apply {
|
||||
setIcon(R.drawable.speed)
|
||||
setTitle(R.string.video_speed)
|
||||
setSingleChoiceItems(speedChoices.map { it.toString() + "x" }.toTypedArray(), speed) { dialog, which ->
|
||||
// update the selected item which is selected by the user so that it should be selected
|
||||
// when user opens the dialog next time and pass the instance to setSingleChoiceItems method
|
||||
speed = which
|
||||
|
||||
// when selected an item the dialog should be closed with the dismiss method
|
||||
dialog.dismiss()
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
}.show()
|
||||
}
|
||||
|
||||
binding.stabilizer.setOnClickListener {
|
||||
AlertDialog.Builder(this).apply {
|
||||
setIcon(R.drawable.video_stable)
|
||||
setTitle(R.string.stabilize_video_intensity)
|
||||
val slider = Slider(context).apply {
|
||||
valueFrom = 0f
|
||||
valueTo = 100f
|
||||
value = stabilization
|
||||
}
|
||||
setView(slider)
|
||||
setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
setPositiveButton(android.R.string.ok) { _, _ -> stabilization = slider.value}
|
||||
}.show()
|
||||
}
|
||||
|
||||
|
||||
val thumbInterval: Float? = duration?.div(7)
|
||||
|
||||
thumbInterval?.let {
|
||||
thumbnail(uri, resultHandler, binding.thumbnail1, it)
|
||||
thumbnail(uri, resultHandler, binding.thumbnail2, it.times(2))
|
||||
thumbnail(uri, resultHandler, binding.thumbnail3, it.times(3))
|
||||
thumbnail(uri, resultHandler, binding.thumbnail4, it.times(4))
|
||||
thumbnail(uri, resultHandler, binding.thumbnail5, it.times(5))
|
||||
thumbnail(uri, resultHandler, binding.thumbnail6, it.times(6))
|
||||
thumbnail(uri, resultHandler, binding.thumbnail7, it.times(7))
|
||||
}
|
||||
|
||||
resetControls()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.edit_menu, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
|
||||
when(item.itemId) {
|
||||
R.id.action_save -> {
|
||||
returnWithValues()
|
||||
}
|
||||
R.id.action_reset -> {
|
||||
resetControls()
|
||||
}
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if(binding.cropImageView.isVisible) {
|
||||
showCropInterface(false)
|
||||
} else if (noEdits()) super.onBackPressed()
|
||||
else {
|
||||
val builder = AlertDialog.Builder(this)
|
||||
builder.apply {
|
||||
setMessage(R.string.save_before_returning)
|
||||
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
returnWithValues()
|
||||
}
|
||||
setNegativeButton(R.string.no_cancel_edit) { _, _ ->
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
// Create the AlertDialog
|
||||
builder.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun noEdits(): Boolean {
|
||||
val videoPositions = binding.videoRangeSeekBar.values.let {
|
||||
it[0] == 0f && it[2] == binding.videoRangeSeekBar.valueTo
|
||||
}
|
||||
val muted = binding.muter.isSelected
|
||||
val speedUnchanged = speed == 1
|
||||
|
||||
val stabilizationUnchanged = stabilization <= 0.01f || stabilization > 100.5f
|
||||
|
||||
return !muted && videoPositions && speedUnchanged && cropRelativeDimensions.notCropped() && stabilizationUnchanged
|
||||
}
|
||||
|
||||
private fun showCropInterface(show: Boolean, uri: Uri? = null){
|
||||
val visibilityOfOthers = if(show) View.GONE else View.VISIBLE
|
||||
val visibilityOfCrop = if(show) View.VISIBLE else View.GONE
|
||||
|
||||
if(show) mediaPlayer.pause()
|
||||
|
||||
if(show) binding.cropSavedCard.visibility = View.GONE
|
||||
else if(!cropRelativeDimensions.notCropped()) binding.cropSavedCard.visibility = View.VISIBLE
|
||||
|
||||
binding.stabilisationSaved.visibility =
|
||||
if(!show && stabilization > 0.01f && stabilization <= 100f) View.VISIBLE
|
||||
else View.GONE
|
||||
|
||||
binding.muter.visibility = visibilityOfOthers
|
||||
binding.speeder.visibility = visibilityOfOthers
|
||||
binding.cropper.visibility = visibilityOfOthers
|
||||
binding.stabilizer.visibility = visibilityOfOthers
|
||||
binding.videoRangeSeekBar.visibility = visibilityOfOthers
|
||||
binding.videoView.visibility = visibilityOfOthers
|
||||
binding.thumbnail1.visibility = visibilityOfOthers
|
||||
binding.thumbnail2.visibility = visibilityOfOthers
|
||||
binding.thumbnail3.visibility = visibilityOfOthers
|
||||
binding.thumbnail4.visibility = visibilityOfOthers
|
||||
binding.thumbnail5.visibility = visibilityOfOthers
|
||||
binding.thumbnail6.visibility = visibilityOfOthers
|
||||
binding.thumbnail7.visibility = visibilityOfOthers
|
||||
|
||||
|
||||
binding.cropImageView.visibility = visibilityOfCrop
|
||||
binding.saveCropButton.visibility = visibilityOfCrop
|
||||
|
||||
if(show && uri != null) binding.cropImageView.setImageUriAsync(uri, cropRelativeDimensions)
|
||||
}
|
||||
|
||||
private fun returnWithValues() {
|
||||
//TODO Check if some of these should be null to indicate no changes in that category? Ex start/end
|
||||
val intent = Intent(this, PostCreationActivity::class.java)
|
||||
.apply {
|
||||
putExtra(PhotoEditActivity.PICTURE_POSITION, videoPosition)
|
||||
putExtra(MUTED, binding.muter.isSelected)
|
||||
putExtra(SPEED, speed)
|
||||
putExtra(MODIFIED, !noEdits())
|
||||
putExtra(VIDEO_START, binding.videoRangeSeekBar.values.first())
|
||||
putExtra(VIDEO_END, binding.videoRangeSeekBar.values[2])
|
||||
putExtra(VIDEO_CROP, cropRelativeDimensions)
|
||||
putExtra(VIDEO_STABILIZE, stabilization)
|
||||
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
||||
}
|
||||
|
||||
setResult(Activity.RESULT_OK, intent)
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun resetControls() {
|
||||
binding.videoRangeSeekBar.values = listOf(0f, binding.videoRangeSeekBar.valueTo/2, binding.videoRangeSeekBar.valueTo)
|
||||
binding.muter.isSelected = false
|
||||
|
||||
binding.cropImageView.resetCropRect()
|
||||
cropRelativeDimensions = RelativeCropPosition()
|
||||
binding.cropper.drawable.setTintList(null)
|
||||
binding.stabilizer.drawable.setTintList(null)
|
||||
binding.cropSavedCard.visibility = View.GONE
|
||||
stabilization = 0f
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
sessionList.forEach {
|
||||
FFmpegKit.cancel(it)
|
||||
}
|
||||
tempFiles.forEach{
|
||||
it.delete()
|
||||
}
|
||||
mediaPlayer.close()
|
||||
}
|
||||
|
||||
private fun thumbnail(
|
||||
inputUri: Uri?,
|
||||
resultHandler: Handler,
|
||||
thumbnail: ImageView,
|
||||
thumbTime: Float,
|
||||
) {
|
||||
val file = File.createTempFile("temp_img", ".bmp", cacheDir)
|
||||
tempFiles.add(file)
|
||||
val fileUri = file.toUri()
|
||||
val ffmpegCompliantUri = ffmpegCompliantUri(inputUri)
|
||||
|
||||
val outputImagePath =
|
||||
if(fileUri.toString().startsWith("content://"))
|
||||
FFmpegKitConfig.getSafParameterForWrite(this, fileUri)
|
||||
else fileUri.toString()
|
||||
val session = FFmpegKit.executeWithArgumentsAsync(arrayOf(
|
||||
"-noaccurate_seek", "-ss", "$thumbTime", "-i", ffmpegCompliantUri, "-vf",
|
||||
"scale=${thumbnail.width}:${thumbnail.height}",
|
||||
"-frames:v", "1", "-f", "image2", "-y", outputImagePath), { session ->
|
||||
val state = session.state
|
||||
val returnCode = session.returnCode
|
||||
|
||||
if (ReturnCode.isSuccess(returnCode)) {
|
||||
// SUCCESS
|
||||
resultHandler.post {
|
||||
if(!this.isFinishing)
|
||||
Glide.with(this).load(outputImagePath).centerCrop().into(thumbnail)
|
||||
}
|
||||
}
|
||||
// CALLED WHEN SESSION IS EXECUTED
|
||||
Log.d("VideoEditActivity", "FFmpeg process exited with state $state and rc $returnCode.${session.failStackTrace}")
|
||||
},
|
||||
{/* CALLED WHEN SESSION PRINTS LOGS */ }, { /*CALLED WHEN SESSION GENERATES STATISTICS*/ })
|
||||
sessionList.add(session.sessionId)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
mediaPlayer.pause()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val VIDEO_TAG = "VideoEditTag"
|
||||
const val MUTED = "VideoEditMutedTag"
|
||||
const val SPEED = "VideoEditSpeedTag"
|
||||
// List of choices of speeds
|
||||
val speedChoices: List<Number> = listOf(0.5, 1, 2, 4, 8)
|
||||
const val VIDEO_START = "VideoEditVideoStartTag"
|
||||
const val VIDEO_END = "VideoEditVideoEndTag"
|
||||
const val VIDEO_CROP = "VideoEditVideoCropTag"
|
||||
const val VIDEO_STABILIZE = "VideoEditVideoStabilizeTag"
|
||||
const val MODIFIED = "VideoEditModifiedTag"
|
||||
}
|
||||
}
|
|
@ -1,105 +0,0 @@
|
|||
package org.pixeldroid.app.postCreation.photoEdit.cropper
|
||||
|
||||
// Simplified version of https://github.com/ArthurHub/Android-Image-Cropper , which is
|
||||
// licensed under the Apache License, Version 2.0. The modifications made to it for PixelDroid
|
||||
// are under licensed under the GPLv3 or later, just like the rest of the PixelDroid project
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.graphics.toRect
|
||||
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 org.pixeldroid.app.databinding.CropImageViewBinding
|
||||
import org.pixeldroid.app.postCreation.photoEdit.VideoEditActivity
|
||||
|
||||
|
||||
/** Custom view that provides cropping capabilities to an image. */
|
||||
class CropImageView @JvmOverloads constructor(context: Context?, attrs: AttributeSet? = null) :
|
||||
FrameLayout(context!!, attrs) {
|
||||
|
||||
|
||||
private val binding: CropImageViewBinding =
|
||||
CropImageViewBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
|
||||
init {
|
||||
binding.CropOverlayView.setInitialAttributeValues()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the crop window's position relative to the parent's view at screen.
|
||||
*
|
||||
* @return a Rect instance containing notCropped area boundaries of the source Bitmap
|
||||
*/
|
||||
val cropWindowRect: RectF
|
||||
get() = binding.CropOverlayView.cropWindowRect
|
||||
|
||||
|
||||
/** Reset crop window to initial rectangle. */
|
||||
fun resetCropRect() {
|
||||
binding.CropOverlayView.resetCropWindowRect()
|
||||
}
|
||||
|
||||
fun getInitialCropWindowRect(): Rect = binding.CropOverlayView.initialCropWindowRect
|
||||
|
||||
/**
|
||||
* Sets the image loaded from the given URI as the content of the CropImageView
|
||||
*
|
||||
* @param uri the URI to load the image from
|
||||
*/
|
||||
fun setImageUriAsync(uri: Uri, cropRelativeDimensions: VideoEditActivity.RelativeCropPosition) {
|
||||
// either no existing task is working or we canceled it, need to load new URI
|
||||
binding.CropOverlayView.initialCropWindowRect = Rect()
|
||||
|
||||
Glide.with(this).load(uri).fitCenter().listener(object : RequestListener<Drawable> {
|
||||
override fun onLoadFailed(
|
||||
e: GlideException?,
|
||||
m: Any?,
|
||||
t: Target<Drawable>?,
|
||||
i: Boolean,
|
||||
): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onResourceReady(
|
||||
resource: Drawable?,
|
||||
model: Any?,
|
||||
target: Target<Drawable>?,
|
||||
dataSource: DataSource?,
|
||||
isFirstResource: Boolean,
|
||||
): Boolean {
|
||||
// Get width and height that the image will take on the screen
|
||||
val drawnWidth = resource?.intrinsicWidth ?: width
|
||||
val drawnHeight = resource?.intrinsicHeight ?: height
|
||||
|
||||
binding.CropOverlayView.initialCropWindowRect = RectF(
|
||||
(width - drawnWidth) / 2f,
|
||||
(height - drawnHeight) / 2f,
|
||||
(width + drawnWidth) / 2f,
|
||||
(height + drawnHeight) / 2f
|
||||
).toRect()
|
||||
binding.CropOverlayView.setCropWindowLimits(
|
||||
drawnWidth.toFloat(),
|
||||
drawnHeight.toFloat()
|
||||
)
|
||||
binding.CropOverlayView.invalidate()
|
||||
binding.CropOverlayView.setBounds(width, height)
|
||||
binding.CropOverlayView.resetCropOverlayView()
|
||||
if (!cropRelativeDimensions.notCropped()) binding.CropOverlayView.setRecordedCropWindowRect(cropRelativeDimensions)
|
||||
binding.CropOverlayView.visibility = VISIBLE
|
||||
|
||||
|
||||
// Indicate to Glide that the image hasn't been set yet
|
||||
return false
|
||||
}
|
||||
}).into(binding.ImageViewImage)
|
||||
}
|
||||
}
|
|
@ -1,490 +0,0 @@
|
|||
package org.pixeldroid.app.postCreation.photoEdit.cropper
|
||||
|
||||
// Simplified version of https://github.com/ArthurHub/Android-Image-Cropper , which is
|
||||
// licensed under the Apache License, Version 2.0. The modifications made to it for PixelDroid
|
||||
// are under licensed under the GPLv3 or later, just like the rest of the PixelDroid project
|
||||
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import org.pixeldroid.app.postCreation.photoEdit.VideoEditActivity.RelativeCropPosition
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/** A custom View representing the crop window and the shaded background outside the crop window. */
|
||||
class CropOverlayView // endregion
|
||||
@JvmOverloads constructor(context: Context?, attrs: AttributeSet? = null) : View(context, attrs) {
|
||||
// region: Fields and Consts
|
||||
/** Handler from crop window stuff, moving and knowing position. */
|
||||
private val mCropWindowHandler = CropWindowHandler()
|
||||
|
||||
/** The Paint used to draw the white rectangle around the crop area. */
|
||||
private var mBorderPaint: Paint? = null
|
||||
|
||||
/** The Paint used to draw the corners of the Border */
|
||||
private var mBorderCornerPaint: Paint? = null
|
||||
|
||||
/** The Paint used to draw the guidelines within the crop area when pressed. */
|
||||
private var mGuidelinePaint: Paint? = null
|
||||
|
||||
/** The bounding box around the Bitmap that we are cropping. */
|
||||
private val mCalcBounds = RectF()
|
||||
|
||||
/** The bounding image view width used to know the crop overlay is at view edges. */
|
||||
private var mViewWidth = 0
|
||||
|
||||
/** The bounding image view height used to know the crop overlay is at view edges. */
|
||||
private var mViewHeight = 0
|
||||
|
||||
/** The Handle that is currently pressed; null if no Handle is pressed. */
|
||||
private var mMoveHandler: CropWindowMoveHandler? = null
|
||||
|
||||
/** the initial crop window rectangle to set */
|
||||
private val mInitialCropWindowRect = Rect()
|
||||
|
||||
/** Whether the Crop View has been initialized for the first time */
|
||||
private var initializedCropWindow = false
|
||||
/** Get the left/top/right/bottom coordinates of the crop window. */
|
||||
/** Set the left/top/right/bottom coordinates of the crop window. */
|
||||
var cropWindowRect: RectF
|
||||
get() = mCropWindowHandler.rect
|
||||
set(rect) {
|
||||
mCropWindowHandler.rect = rect
|
||||
}
|
||||
|
||||
/**
|
||||
* Informs the CropOverlayView of the image's position relative to the ImageView. This is
|
||||
* necessary to call in order to draw the crop window.
|
||||
*
|
||||
* @param viewWidth The bounding image view width.
|
||||
* @param viewHeight The bounding image view height.
|
||||
*/
|
||||
fun setBounds(viewWidth: Int, viewHeight: Int) {
|
||||
mViewWidth = viewWidth
|
||||
mViewHeight = viewHeight
|
||||
val cropRect = mCropWindowHandler.rect
|
||||
if (cropRect.width() == 0f || cropRect.height() == 0f) {
|
||||
initCropWindow()
|
||||
}
|
||||
}
|
||||
|
||||
/** Resets the crop overlay view. */
|
||||
fun resetCropOverlayView() {
|
||||
if (initializedCropWindow) {
|
||||
cropWindowRect = RectF()
|
||||
initCropWindow()
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the max width/height and scale factor of the shown image to original image to scale the
|
||||
* limits appropriately.
|
||||
*/
|
||||
fun setCropWindowLimits(maxWidth: Float, maxHeight: Float) {
|
||||
mCropWindowHandler.setCropWindowLimits(maxWidth, maxHeight)
|
||||
}
|
||||
/** Get crop window initial rectangle. */
|
||||
/** Set crop window initial rectangle to be used instead of default. */
|
||||
var initialCropWindowRect: Rect
|
||||
get() = mInitialCropWindowRect
|
||||
set(rect) {
|
||||
mInitialCropWindowRect.set(rect)
|
||||
if (initializedCropWindow) {
|
||||
initCropWindow()
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
fun setRecordedCropWindowRect(relativeCropPosition: RelativeCropPosition) {
|
||||
val rect = RectF(
|
||||
mInitialCropWindowRect.left + relativeCropPosition.relativeX * mInitialCropWindowRect.width(),
|
||||
mInitialCropWindowRect.top + relativeCropPosition.relativeY * mInitialCropWindowRect.height(),
|
||||
relativeCropPosition.relativeWidth * mInitialCropWindowRect.width() + mInitialCropWindowRect.left + relativeCropPosition.relativeX * mInitialCropWindowRect.width(),
|
||||
relativeCropPosition.relativeHeight * mInitialCropWindowRect.height() + mInitialCropWindowRect.top + relativeCropPosition.relativeY * mInitialCropWindowRect.height()
|
||||
)
|
||||
mCropWindowHandler.rect = rect
|
||||
}
|
||||
|
||||
/** Reset crop window to initial rectangle. */
|
||||
fun resetCropWindowRect() {
|
||||
if (initializedCropWindow) {
|
||||
initCropWindow()
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets all initial values, but does not call initCropWindow to reset the views.<br></br>
|
||||
* Used once at the very start to initialize the attributes.
|
||||
*/
|
||||
fun setInitialAttributeValues() {
|
||||
val dm = Resources.getSystem().displayMetrics
|
||||
mBorderPaint = getNewPaintOfThickness(
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3f, dm),
|
||||
Color.argb(170, 255, 255, 255)
|
||||
)
|
||||
mBorderCornerPaint = getNewPaintOfThickness(
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, dm),
|
||||
Color.WHITE
|
||||
)
|
||||
mGuidelinePaint = getNewPaintOfThickness(
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1f, dm),
|
||||
Color.argb(170, 255, 255, 255)
|
||||
)
|
||||
}
|
||||
// region: Private methods
|
||||
/**
|
||||
* Set the initial crop window size and position. This is dependent on the size and position of
|
||||
* the image being cropped.
|
||||
*/
|
||||
private fun initCropWindow() {
|
||||
val rect = RectF()
|
||||
|
||||
// Tells the attribute functions the crop window has already been initialized
|
||||
initializedCropWindow = true
|
||||
if (mInitialCropWindowRect.width() > 0 && mInitialCropWindowRect.height() > 0) {
|
||||
// Get crop window position relative to the displayed image.
|
||||
rect.left = mInitialCropWindowRect.left.toFloat()
|
||||
rect.top = mInitialCropWindowRect.top.toFloat()
|
||||
rect.right = rect.left + mInitialCropWindowRect.width()
|
||||
rect.bottom = rect.top + mInitialCropWindowRect.height()
|
||||
}
|
||||
fixCropWindowRectByRules(rect)
|
||||
mCropWindowHandler.rect = rect
|
||||
}
|
||||
|
||||
/** Fix the given rect to fit into bitmap rect and follow min, max and aspect ratio rules. */
|
||||
private fun fixCropWindowRectByRules(rect: RectF) {
|
||||
if (rect.width() < mCropWindowHandler.minCropWidth) {
|
||||
val adj = (mCropWindowHandler.minCropWidth - rect.width()) / 2
|
||||
rect.left -= adj
|
||||
rect.right += adj
|
||||
}
|
||||
if (rect.height() < mCropWindowHandler.minCropHeight) {
|
||||
val adj = (mCropWindowHandler.minCropHeight - rect.height()) / 2
|
||||
rect.top -= adj
|
||||
rect.bottom += adj
|
||||
}
|
||||
if (rect.width() > mCropWindowHandler.maxCropWidth) {
|
||||
val adj = (rect.width() - mCropWindowHandler.maxCropWidth) / 2
|
||||
rect.left += adj
|
||||
rect.right -= adj
|
||||
}
|
||||
if (rect.height() > mCropWindowHandler.maxCropHeight) {
|
||||
val adj = (rect.height() - mCropWindowHandler.maxCropHeight) / 2
|
||||
rect.top += adj
|
||||
rect.bottom -= adj
|
||||
}
|
||||
setBounds()
|
||||
if (mCalcBounds.width() > 0 && mCalcBounds.height() > 0) {
|
||||
val leftLimit = max(mCalcBounds.left, 0f)
|
||||
val topLimit = max(mCalcBounds.top, 0f)
|
||||
val rightLimit = min(mCalcBounds.right, width.toFloat())
|
||||
val bottomLimit = min(mCalcBounds.bottom, height.toFloat())
|
||||
if (rect.left < leftLimit) {
|
||||
rect.left = leftLimit
|
||||
}
|
||||
if (rect.top < topLimit) {
|
||||
rect.top = topLimit
|
||||
}
|
||||
if (rect.right > rightLimit) {
|
||||
rect.right = rightLimit
|
||||
}
|
||||
if (rect.bottom > bottomLimit) {
|
||||
rect.bottom = bottomLimit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw crop overview by drawing background over image not in the cropping area, then borders and
|
||||
* guidelines.
|
||||
*/
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
// Draw translucent background for the notCropped area.
|
||||
drawBackground(canvas)
|
||||
if (mCropWindowHandler.showGuidelines()) {
|
||||
// Determines whether guidelines should be drawn or not
|
||||
if (mMoveHandler != null) {
|
||||
// Draw only when resizing
|
||||
drawGuidelines(canvas)
|
||||
}
|
||||
}
|
||||
drawBorders(canvas)
|
||||
drawCorners(canvas)
|
||||
}
|
||||
|
||||
/** Draw shadow background over the image not including the crop area. */
|
||||
private fun drawBackground(canvas: Canvas) {
|
||||
val rect = mCropWindowHandler.rect
|
||||
val background = getNewPaint(Color.argb(119, 0, 0, 0))
|
||||
canvas.drawRect(
|
||||
mInitialCropWindowRect.left.toFloat(),
|
||||
mInitialCropWindowRect.top.toFloat(),
|
||||
rect.left,
|
||||
mInitialCropWindowRect.bottom.toFloat(),
|
||||
background
|
||||
)
|
||||
canvas.drawRect(
|
||||
rect.left,
|
||||
rect.bottom,
|
||||
mInitialCropWindowRect.right.toFloat(),
|
||||
mInitialCropWindowRect.bottom.toFloat(),
|
||||
background
|
||||
)
|
||||
canvas.drawRect(
|
||||
rect.right,
|
||||
mInitialCropWindowRect.top.toFloat(),
|
||||
mInitialCropWindowRect.right.toFloat(),
|
||||
rect.bottom,
|
||||
background
|
||||
)
|
||||
canvas.drawRect(
|
||||
rect.left,
|
||||
mInitialCropWindowRect.top.toFloat(),
|
||||
rect.right,
|
||||
rect.top,
|
||||
background
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw 2 vertical and 2 horizontal guidelines inside the cropping area to split it into 9 equal
|
||||
* parts.
|
||||
*/
|
||||
private fun drawGuidelines(canvas: Canvas) {
|
||||
if (mGuidelinePaint != null) {
|
||||
val sw: Float = if (mBorderPaint != null) mBorderPaint!!.strokeWidth else 0f
|
||||
val rect = mCropWindowHandler.rect
|
||||
rect.inset(sw, sw)
|
||||
val oneThirdCropWidth = rect.width() / 3
|
||||
val oneThirdCropHeight = rect.height() / 3
|
||||
|
||||
// Draw vertical guidelines.
|
||||
val x1 = rect.left + oneThirdCropWidth
|
||||
val x2 = rect.right - oneThirdCropWidth
|
||||
canvas.drawLine(x1, rect.top, x1, rect.bottom, mGuidelinePaint!!)
|
||||
canvas.drawLine(x2, rect.top, x2, rect.bottom, mGuidelinePaint!!)
|
||||
|
||||
// Draw horizontal guidelines.
|
||||
val y1 = rect.top + oneThirdCropHeight
|
||||
val y2 = rect.bottom - oneThirdCropHeight
|
||||
canvas.drawLine(rect.left, y1, rect.right, y1, mGuidelinePaint!!)
|
||||
canvas.drawLine(rect.left, y2, rect.right, y2, mGuidelinePaint!!)
|
||||
}
|
||||
}
|
||||
|
||||
/** Draw borders of the crop area. */
|
||||
private fun drawBorders(canvas: Canvas) {
|
||||
if (mBorderPaint != null) {
|
||||
val w = mBorderPaint!!.strokeWidth
|
||||
val rect = mCropWindowHandler.rect
|
||||
// Make the rectangle a bit smaller to accommodate for the border
|
||||
rect.inset(w / 2, w / 2)
|
||||
|
||||
// Draw rectangle crop window border.
|
||||
canvas.drawRect(rect, mBorderPaint!!)
|
||||
}
|
||||
}
|
||||
|
||||
/** Draw the corner of crop overlay. */
|
||||
private fun drawCorners(canvas: Canvas) {
|
||||
val dm = Resources.getSystem().displayMetrics
|
||||
if (mBorderCornerPaint != null) {
|
||||
val lineWidth: Float = if (mBorderPaint != null) mBorderPaint!!.strokeWidth else 0f
|
||||
val cornerWidth = mBorderCornerPaint!!.strokeWidth
|
||||
|
||||
// The corners should be a bit offset from the borders
|
||||
val w = (cornerWidth / 2
|
||||
+ TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, dm))
|
||||
val rect = mCropWindowHandler.rect
|
||||
rect.inset(w, w)
|
||||
val cornerOffset = (cornerWidth - lineWidth) / 2
|
||||
val cornerExtension = cornerWidth / 2 + cornerOffset
|
||||
|
||||
/* the length of the border corner to draw */
|
||||
val mBorderCornerLength =
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14f, dm)
|
||||
|
||||
// Top left
|
||||
canvas.drawLine(
|
||||
rect.left - cornerOffset,
|
||||
rect.top - cornerExtension,
|
||||
rect.left - cornerOffset,
|
||||
rect.top + mBorderCornerLength,
|
||||
mBorderCornerPaint!!
|
||||
)
|
||||
canvas.drawLine(
|
||||
rect.left - cornerExtension,
|
||||
rect.top - cornerOffset,
|
||||
rect.left + mBorderCornerLength,
|
||||
rect.top - cornerOffset,
|
||||
mBorderCornerPaint!!
|
||||
)
|
||||
|
||||
// Top right
|
||||
canvas.drawLine(
|
||||
rect.right + cornerOffset,
|
||||
rect.top - cornerExtension,
|
||||
rect.right + cornerOffset,
|
||||
rect.top + mBorderCornerLength,
|
||||
mBorderCornerPaint!!
|
||||
)
|
||||
canvas.drawLine(
|
||||
rect.right + cornerExtension,
|
||||
rect.top - cornerOffset,
|
||||
rect.right - mBorderCornerLength,
|
||||
rect.top - cornerOffset,
|
||||
mBorderCornerPaint!!
|
||||
)
|
||||
|
||||
// Bottom left
|
||||
canvas.drawLine(
|
||||
rect.left - cornerOffset,
|
||||
rect.bottom + cornerExtension,
|
||||
rect.left - cornerOffset,
|
||||
rect.bottom - mBorderCornerLength,
|
||||
mBorderCornerPaint!!
|
||||
)
|
||||
canvas.drawLine(
|
||||
rect.left - cornerExtension,
|
||||
rect.bottom + cornerOffset,
|
||||
rect.left + mBorderCornerLength,
|
||||
rect.bottom + cornerOffset,
|
||||
mBorderCornerPaint!!
|
||||
)
|
||||
|
||||
// Bottom left
|
||||
canvas.drawLine(
|
||||
rect.right + cornerOffset,
|
||||
rect.bottom + cornerExtension,
|
||||
rect.right + cornerOffset,
|
||||
rect.bottom - mBorderCornerLength,
|
||||
mBorderCornerPaint!!
|
||||
)
|
||||
canvas.drawLine(
|
||||
rect.right + cornerExtension,
|
||||
rect.bottom + cornerOffset,
|
||||
rect.right - mBorderCornerLength,
|
||||
rect.bottom + cornerOffset,
|
||||
mBorderCornerPaint!!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
// If this View is not enabled, don't allow for touch interactions.
|
||||
return if (isEnabled) {
|
||||
/* Boolean to see if multi touch is enabled for the crop rectangle */
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
onActionDown(event.x, event.y)
|
||||
true
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
parent.requestDisallowInterceptTouchEvent(false)
|
||||
onActionUp()
|
||||
true
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
onActionMove(event.x, event.y)
|
||||
parent.requestDisallowInterceptTouchEvent(true)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On press down start crop window movement depending on the location of the press.<br></br>
|
||||
* if press is far from crop window then no move handler is returned (null).
|
||||
*/
|
||||
private fun onActionDown(x: Float, y: Float) {
|
||||
val dm = Resources.getSystem().displayMetrics
|
||||
mMoveHandler = mCropWindowHandler.getMoveHandler(
|
||||
x,
|
||||
y,
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 24f, dm)
|
||||
)
|
||||
if (mMoveHandler != null) {
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear move handler starting in [.onActionDown] if exists. */
|
||||
private fun onActionUp() {
|
||||
if (mMoveHandler != null) {
|
||||
mMoveHandler = null
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle move of crop window using the move handler created in [.onActionDown].<br></br>
|
||||
* The move handler will do the proper move/resize of the crop window.
|
||||
*/
|
||||
private fun onActionMove(x: Float, y: Float) {
|
||||
if (mMoveHandler != null) {
|
||||
val rect = mCropWindowHandler.rect
|
||||
setBounds()
|
||||
val dm = Resources.getSystem().displayMetrics
|
||||
val snapRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3f, dm)
|
||||
mMoveHandler!!.move(
|
||||
rect,
|
||||
x,
|
||||
y,
|
||||
mCalcBounds,
|
||||
mViewWidth,
|
||||
mViewHeight,
|
||||
snapRadius
|
||||
)
|
||||
mCropWindowHandler.rect = rect
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the bounding rectangle for current crop window
|
||||
* The bounds rectangle is the bitmap rectangle
|
||||
*/
|
||||
private fun setBounds() {
|
||||
mCalcBounds.set(mInitialCropWindowRect)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Creates the Paint object for drawing. */
|
||||
private fun getNewPaint(color: Int): Paint {
|
||||
val paint = Paint()
|
||||
paint.color = color
|
||||
return paint
|
||||
}
|
||||
|
||||
/** Creates the Paint object for given thickness and color */
|
||||
private fun getNewPaintOfThickness(thickness: Float, color: Int): Paint {
|
||||
val borderPaint = Paint()
|
||||
borderPaint.color = color
|
||||
borderPaint.strokeWidth = thickness
|
||||
borderPaint.style = Paint.Style.STROKE
|
||||
borderPaint.isAntiAlias = true
|
||||
return borderPaint
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,269 +0,0 @@
|
|||
package org.pixeldroid.app.postCreation.photoEdit.cropper
|
||||
|
||||
// Simplified version of https://github.com/ArthurHub/Android-Image-Cropper , which is
|
||||
// licensed under the Apache License, Version 2.0. The modifications made to it for PixelDroid
|
||||
// are under licensed under the GPLv3 or later, just like the rest of the PixelDroid project
|
||||
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.graphics.RectF
|
||||
import android.util.TypedValue
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/** Handler from crop window stuff, moving and knowing position. */
|
||||
internal class CropWindowHandler {
|
||||
/** The 4 edges of the crop window defining its coordinates and size */
|
||||
private val mEdges = RectF()
|
||||
|
||||
/**
|
||||
* Rectangle used to return the edges rectangle without ability to change it and without
|
||||
* creating new all the time.
|
||||
*/
|
||||
private val mGetEdges = RectF()
|
||||
|
||||
/** Maximum width in pixels that the crop window can CURRENTLY get. */
|
||||
private var mMaxCropWindowWidth = 0f
|
||||
|
||||
/** Maximum height in pixels that the crop window can CURRENTLY get. */
|
||||
private var mMaxCropWindowHeight = 0f
|
||||
|
||||
/** The left/top/right/bottom coordinates of the crop window. */
|
||||
var rect: RectF
|
||||
get() {
|
||||
mGetEdges.set(mEdges)
|
||||
return mGetEdges
|
||||
}
|
||||
set(rect) {
|
||||
mEdges.set(rect)
|
||||
}
|
||||
|
||||
/** Minimum width in pixels that the crop window can get. */
|
||||
val minCropWidth: Float
|
||||
get() {
|
||||
val dm = Resources.getSystem().displayMetrics
|
||||
val mMinCropResultWidth = 40f
|
||||
return max(
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 42f, dm).toInt().toFloat(),
|
||||
mMinCropResultWidth
|
||||
)
|
||||
}
|
||||
|
||||
/** Minimum height in pixels that the crop window can get. */
|
||||
val minCropHeight: Float
|
||||
get() {
|
||||
val dm = Resources.getSystem().displayMetrics
|
||||
val mMinCropResultHeight = 40f
|
||||
return max(
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 42f, dm).toInt().toFloat(),
|
||||
mMinCropResultHeight
|
||||
)
|
||||
}
|
||||
|
||||
/** Maximum width in pixels that the crop window can get. */
|
||||
val maxCropWidth: Float
|
||||
get() {
|
||||
val mMaxCropResultWidth = 99999f
|
||||
return min(mMaxCropWindowWidth, mMaxCropResultWidth)
|
||||
}
|
||||
|
||||
/** Maximum height in pixels that the crop window can get. */
|
||||
val maxCropHeight: Float
|
||||
get() {
|
||||
val mMaxCropResultHeight = 99999f
|
||||
return min(mMaxCropWindowHeight, mMaxCropResultHeight)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the max width/height of the shown image to original image to scale the limits appropriately
|
||||
*/
|
||||
fun setCropWindowLimits(maxWidth: Float, maxHeight: Float) {
|
||||
mMaxCropWindowWidth = maxWidth
|
||||
mMaxCropWindowHeight = maxHeight
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the crop window is small enough that the guidelines should be shown. Public
|
||||
* because this function is also used to determine if the center handle should be focused.
|
||||
*
|
||||
* @return boolean Whether the guidelines should be shown or not
|
||||
*/
|
||||
fun showGuidelines(): Boolean {
|
||||
return !(mEdges.width() < 100 || mEdges.height() < 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines which, if any, of the handles are pressed given the touch coordinates, the bounding
|
||||
* box, and the touch radius.
|
||||
*
|
||||
* @param x the x-coordinate of the touch point
|
||||
* @param y the y-coordinate of the touch point
|
||||
* @param targetRadius the target radius in pixels
|
||||
* @return the Handle that was pressed; null if no Handle was pressed
|
||||
*/
|
||||
fun getMoveHandler(x: Float, y: Float, targetRadius: Float): CropWindowMoveHandler? {
|
||||
val type = getRectanglePressedMoveType(x, y, targetRadius)
|
||||
return if (type != null) CropWindowMoveHandler(type, this, x, y) else null
|
||||
}
|
||||
// region: Private methods
|
||||
/**
|
||||
* Determines which, if any, of the handles are pressed given the touch coordinates, the bounding
|
||||
* box, and the touch radius.
|
||||
*
|
||||
* @param x the x-coordinate of the touch point
|
||||
* @param y the y-coordinate of the touch point
|
||||
* @param targetRadius the target radius in pixels
|
||||
* @return the Handle that was pressed; null if no Handle was pressed
|
||||
*/
|
||||
private fun getRectanglePressedMoveType(
|
||||
x: Float, y: Float, targetRadius: Float
|
||||
): CropWindowMoveHandler.Type? {
|
||||
var moveType: CropWindowMoveHandler.Type? = null
|
||||
|
||||
// Note: corner-handles take precedence, then side-handles, then center.
|
||||
if (isInCornerTargetZone(x, y, mEdges.left, mEdges.top, targetRadius)) {
|
||||
moveType = CropWindowMoveHandler.Type.TOP_LEFT
|
||||
} else if (isInCornerTargetZone(
|
||||
x, y, mEdges.right, mEdges.top, targetRadius
|
||||
)
|
||||
) {
|
||||
moveType = CropWindowMoveHandler.Type.TOP_RIGHT
|
||||
} else if (isInCornerTargetZone(
|
||||
x, y, mEdges.left, mEdges.bottom, targetRadius
|
||||
)
|
||||
) {
|
||||
moveType = CropWindowMoveHandler.Type.BOTTOM_LEFT
|
||||
} else if (isInCornerTargetZone(
|
||||
x, y, mEdges.right, mEdges.bottom, targetRadius
|
||||
)
|
||||
) {
|
||||
moveType = CropWindowMoveHandler.Type.BOTTOM_RIGHT
|
||||
} else if (isInCenterTargetZone(
|
||||
x, y, mEdges.left, mEdges.top, mEdges.right, mEdges.bottom
|
||||
)
|
||||
&& focusCenter()
|
||||
) {
|
||||
moveType = CropWindowMoveHandler.Type.CENTER
|
||||
} else if (isInHorizontalTargetZone(
|
||||
x, y, mEdges.left, mEdges.right, mEdges.top, targetRadius
|
||||
)
|
||||
) {
|
||||
moveType = CropWindowMoveHandler.Type.TOP
|
||||
} else if (isInHorizontalTargetZone(
|
||||
x, y, mEdges.left, mEdges.right, mEdges.bottom, targetRadius
|
||||
)
|
||||
) {
|
||||
moveType = CropWindowMoveHandler.Type.BOTTOM
|
||||
} else if (isInVerticalTargetZone(
|
||||
x, y, mEdges.left, mEdges.top, mEdges.bottom, targetRadius
|
||||
)
|
||||
) {
|
||||
moveType = CropWindowMoveHandler.Type.LEFT
|
||||
} else if (isInVerticalTargetZone(
|
||||
x, y, mEdges.right, mEdges.top, mEdges.bottom, targetRadius
|
||||
)
|
||||
) {
|
||||
moveType = CropWindowMoveHandler.Type.RIGHT
|
||||
} else if (isInCenterTargetZone(
|
||||
x, y, mEdges.left, mEdges.top, mEdges.right, mEdges.bottom
|
||||
)
|
||||
&& !focusCenter()
|
||||
) {
|
||||
moveType = CropWindowMoveHandler.Type.CENTER
|
||||
}
|
||||
return moveType
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the cropper should focus on the center handle or the side handles. If it is a
|
||||
* small image, focus on the center handle so the user can move it. If it is a large image, focus
|
||||
* on the side handles so user can grab them. Corresponds to the appearance of the
|
||||
* RuleOfThirdsGuidelines.
|
||||
*
|
||||
* @return true if it is small enough such that it should focus on the center; less than
|
||||
* show_guidelines limit
|
||||
*/
|
||||
private fun focusCenter(): Boolean = !showGuidelines()
|
||||
|
||||
// endregion
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Determines if the specified coordinate is in the target touch zone for a corner handle.
|
||||
*
|
||||
* @param x the x-coordinate of the touch point
|
||||
* @param y the y-coordinate of the touch point
|
||||
* @param handleX the x-coordinate of the corner handle
|
||||
* @param handleY the y-coordinate of the corner handle
|
||||
* @param targetRadius the target radius in pixels
|
||||
* @return true if the touch point is in the target touch zone; false otherwise
|
||||
*/
|
||||
private fun isInCornerTargetZone(
|
||||
x: Float, y: Float, handleX: Float, handleY: Float, targetRadius: Float
|
||||
): Boolean {
|
||||
return abs(x - handleX) <= targetRadius && abs(y - handleY) <= targetRadius
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the specified coordinate is in the target touch zone for a horizontal bar handle.
|
||||
*
|
||||
* @param x the x-coordinate of the touch point
|
||||
* @param y the y-coordinate of the touch point
|
||||
* @param handleXStart the left x-coordinate of the horizontal bar handle
|
||||
* @param handleXEnd the right x-coordinate of the horizontal bar handle
|
||||
* @param handleY the y-coordinate of the horizontal bar handle
|
||||
* @param targetRadius the target radius in pixels
|
||||
* @return true if the touch point is in the target touch zone; false otherwise
|
||||
*/
|
||||
private fun isInHorizontalTargetZone(
|
||||
x: Float,
|
||||
y: Float,
|
||||
handleXStart: Float,
|
||||
handleXEnd: Float,
|
||||
handleY: Float,
|
||||
targetRadius: Float
|
||||
): Boolean {
|
||||
return x > handleXStart && x < handleXEnd && abs(y - handleY) <= targetRadius
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the specified coordinate is in the target touch zone for a vertical bar handle.
|
||||
*
|
||||
* @param x the x-coordinate of the touch point
|
||||
* @param y the y-coordinate of the touch point
|
||||
* @param handleX the x-coordinate of the vertical bar handle
|
||||
* @param handleYStart the top y-coordinate of the vertical bar handle
|
||||
* @param handleYEnd the bottom y-coordinate of the vertical bar handle
|
||||
* @param targetRadius the target radius in pixels
|
||||
* @return true if the touch point is in the target touch zone; false otherwise
|
||||
*/
|
||||
private fun isInVerticalTargetZone(
|
||||
x: Float,
|
||||
y: Float,
|
||||
handleX: Float,
|
||||
handleYStart: Float,
|
||||
handleYEnd: Float,
|
||||
targetRadius: Float
|
||||
): Boolean {
|
||||
return abs(x - handleX) <= targetRadius && y > handleYStart && y < handleYEnd
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the specified coordinate falls anywhere inside the given bounds.
|
||||
*
|
||||
* @param x the x-coordinate of the touch point
|
||||
* @param y the y-coordinate of the touch point
|
||||
* @param left the x-coordinate of the left bound
|
||||
* @param top the y-coordinate of the top bound
|
||||
* @param right the x-coordinate of the right bound
|
||||
* @param bottom the y-coordinate of the bottom bound
|
||||
* @return true if the touch point is inside the bounding rectangle; false otherwise
|
||||
*/
|
||||
private fun isInCenterTargetZone(
|
||||
x: Float, y: Float, left: Float, top: Float, right: Float, bottom: Float
|
||||
): Boolean {
|
||||
return x > left && x < right && y > top && y < bottom
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,405 +0,0 @@
|
|||
package org.pixeldroid.app.postCreation.photoEdit.cropper
|
||||
|
||||
// Simplified version of https://github.com/ArthurHub/Android-Image-Cropper , which is
|
||||
// licensed under the Apache License, Version 2.0. The modifications made to it for PixelDroid
|
||||
// are under licensed under the GPLv3 or later, just like the rest of the PixelDroid project
|
||||
|
||||
import android.graphics.PointF
|
||||
import android.graphics.RectF
|
||||
|
||||
/**
|
||||
* Handler to update crop window edges by the move type - Horizontal, Vertical, Corner or Center.
|
||||
*/
|
||||
internal class CropWindowMoveHandler(
|
||||
/** The type of crop window move that is handled. */
|
||||
private val mType: Type,
|
||||
cropWindowHandler: CropWindowHandler, touchX: Float, touchY: Float
|
||||
) {
|
||||
/** Minimum width in pixels that the crop window can get. */
|
||||
private val mMinCropWidth: Float
|
||||
|
||||
/** Minimum width in pixels that the crop window can get. */
|
||||
private val mMinCropHeight: Float
|
||||
|
||||
/** Maximum height in pixels that the crop window can get. */
|
||||
private val mMaxCropWidth: Float
|
||||
|
||||
/** Maximum height in pixels that the crop window can get. */
|
||||
private val mMaxCropHeight: Float
|
||||
|
||||
/**
|
||||
* Holds the x and y offset between the exact touch location and the exact handle location that is
|
||||
* activated. There may be an offset because we allow for some leeway (specified by mHandleRadius)
|
||||
* in activating a handle. However, we want to maintain these offset values while the handle is
|
||||
* being dragged so that the handle doesn't jump.
|
||||
*/
|
||||
private val mTouchOffset = PointF()
|
||||
|
||||
init {
|
||||
mMinCropWidth = cropWindowHandler.minCropWidth
|
||||
mMinCropHeight = cropWindowHandler.minCropHeight
|
||||
mMaxCropWidth = cropWindowHandler.maxCropWidth
|
||||
mMaxCropHeight = cropWindowHandler.maxCropHeight
|
||||
calculateTouchOffset(cropWindowHandler.rect, touchX, touchY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the crop window by change in the touch location.
|
||||
* Move type handled by this instance, as initialized in creation, affects how the change in
|
||||
* touch location changes the crop window position and size.
|
||||
* After the crop window position/size is changed by touch move it may result in values that
|
||||
* violate constraints: outside the bounds of the shown bitmap, smaller/larger than min/max size or
|
||||
* mismatch in aspect ratio. So a series of fixes is executed on "secondary" edges to adjust it
|
||||
* by the "primary" edge movement.
|
||||
* Primary is the edge directly affected by move type, secondary is the other edge.
|
||||
* The crop window is changed by directly setting the Edge coordinates.
|
||||
*
|
||||
* @param x the new x-coordinate of this handle
|
||||
* @param y the new y-coordinate of this handle
|
||||
* @param bounds the bounding rectangle of the image
|
||||
* @param viewWidth The bounding image view width used to know the crop overlay is at view edges.
|
||||
* @param viewHeight The bounding image view height used to know the crop overlay is at view
|
||||
* edges.
|
||||
* @param snapMargin the maximum distance (in pixels) at which the crop window should snap to the
|
||||
* image
|
||||
*/
|
||||
fun move(
|
||||
rect: RectF,
|
||||
x: Float,
|
||||
y: Float,
|
||||
bounds: RectF,
|
||||
viewWidth: Int,
|
||||
viewHeight: Int,
|
||||
snapMargin: Float
|
||||
) {
|
||||
|
||||
// Adjust the coordinates for the finger position's offset (i.e. the
|
||||
// distance from the initial touch to the precise handle location).
|
||||
// We want to maintain the initial touch's distance to the pressed
|
||||
// handle so that the crop window size does not "jump".
|
||||
val adjX = x + mTouchOffset.x
|
||||
val adjY = y + mTouchOffset.y
|
||||
if (mType == Type.CENTER) {
|
||||
moveCenter(rect, adjX, adjY, bounds, viewWidth, viewHeight, snapMargin)
|
||||
} else {
|
||||
changeSize(rect, adjX, adjY, bounds, viewWidth, viewHeight, snapMargin)
|
||||
}
|
||||
}
|
||||
// region: Private methods
|
||||
/**
|
||||
* Calculates the offset of the touch point from the precise location of the specified handle.<br></br>
|
||||
* Save these values in a member variable since we want to maintain this offset as we drag the
|
||||
* handle.
|
||||
*/
|
||||
private fun calculateTouchOffset(rect: RectF, touchX: Float, touchY: Float) {
|
||||
var touchOffsetX = 0f
|
||||
var touchOffsetY = 0f
|
||||
when (mType) {
|
||||
Type.TOP_LEFT -> {
|
||||
touchOffsetX = rect.left - touchX
|
||||
touchOffsetY = rect.top - touchY
|
||||
}
|
||||
|
||||
Type.TOP_RIGHT -> {
|
||||
touchOffsetX = rect.right - touchX
|
||||
touchOffsetY = rect.top - touchY
|
||||
}
|
||||
|
||||
Type.BOTTOM_LEFT -> {
|
||||
touchOffsetX = rect.left - touchX
|
||||
touchOffsetY = rect.bottom - touchY
|
||||
}
|
||||
|
||||
Type.BOTTOM_RIGHT -> {
|
||||
touchOffsetX = rect.right - touchX
|
||||
touchOffsetY = rect.bottom - touchY
|
||||
}
|
||||
|
||||
Type.LEFT -> {
|
||||
touchOffsetX = rect.left - touchX
|
||||
touchOffsetY = 0f
|
||||
}
|
||||
|
||||
Type.TOP -> {
|
||||
touchOffsetX = 0f
|
||||
touchOffsetY = rect.top - touchY
|
||||
}
|
||||
|
||||
Type.RIGHT -> {
|
||||
touchOffsetX = rect.right - touchX
|
||||
touchOffsetY = 0f
|
||||
}
|
||||
|
||||
Type.BOTTOM -> {
|
||||
touchOffsetX = 0f
|
||||
touchOffsetY = rect.bottom - touchY
|
||||
}
|
||||
|
||||
Type.CENTER -> {
|
||||
touchOffsetX = rect.centerX() - touchX
|
||||
touchOffsetY = rect.centerY() - touchY
|
||||
}
|
||||
}
|
||||
mTouchOffset.x = touchOffsetX
|
||||
mTouchOffset.y = touchOffsetY
|
||||
}
|
||||
|
||||
/** Center move only changes the position of the crop window without changing the size. */
|
||||
private fun moveCenter(
|
||||
rect: RectF,
|
||||
x: Float,
|
||||
y: Float,
|
||||
bounds: RectF,
|
||||
viewWidth: Int,
|
||||
viewHeight: Int,
|
||||
snapRadius: Float
|
||||
) {
|
||||
var dx = x - rect.centerX()
|
||||
var dy = y - rect.centerY()
|
||||
if (rect.left + dx < 0 || rect.right + dx > viewWidth || rect.left + dx < bounds.left || rect.right + dx > bounds.right) {
|
||||
dx /= 1.05f
|
||||
mTouchOffset.x -= dx / 2
|
||||
}
|
||||
if (rect.top + dy < 0 || rect.bottom + dy > viewHeight || rect.top + dy < bounds.top || rect.bottom + dy > bounds.bottom) {
|
||||
dy /= 1.05f
|
||||
mTouchOffset.y -= dy / 2
|
||||
}
|
||||
rect.offset(dx, dy)
|
||||
snapEdgesToBounds(rect, bounds, snapRadius)
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the size of the crop window on the required edge (or edges in the case of a corner)
|
||||
*/
|
||||
private fun changeSize(
|
||||
rect: RectF,
|
||||
x: Float,
|
||||
y: Float,
|
||||
bounds: RectF,
|
||||
viewWidth: Int,
|
||||
viewHeight: Int,
|
||||
snapMargin: Float
|
||||
) {
|
||||
when (mType) {
|
||||
Type.TOP_LEFT -> {
|
||||
adjustTop(rect, y, bounds, snapMargin)
|
||||
adjustLeft(rect, x, bounds, snapMargin)
|
||||
}
|
||||
|
||||
Type.TOP_RIGHT -> {
|
||||
adjustTop(rect, y, bounds, snapMargin)
|
||||
adjustRight(rect, x, bounds, viewWidth, snapMargin)
|
||||
}
|
||||
|
||||
Type.BOTTOM_LEFT -> {
|
||||
adjustBottom(rect, y, bounds, viewHeight, snapMargin)
|
||||
adjustLeft(rect, x, bounds, snapMargin)
|
||||
}
|
||||
|
||||
Type.BOTTOM_RIGHT -> {
|
||||
adjustBottom(rect, y, bounds, viewHeight, snapMargin)
|
||||
adjustRight(rect, x, bounds, viewWidth, snapMargin)
|
||||
}
|
||||
|
||||
Type.LEFT -> adjustLeft(rect, x, bounds, snapMargin)
|
||||
Type.TOP -> adjustTop(rect, y, bounds, snapMargin)
|
||||
Type.RIGHT -> adjustRight(rect, x, bounds, viewWidth, snapMargin)
|
||||
Type.BOTTOM -> adjustBottom(rect, y, bounds, viewHeight, snapMargin)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if edges have gone out of bounds (including snap margin), and fix if needed. */
|
||||
private fun snapEdgesToBounds(edges: RectF, bounds: RectF, margin: Float) {
|
||||
if (edges.left < bounds.left + margin) {
|
||||
edges.offset(bounds.left - edges.left, 0f)
|
||||
}
|
||||
if (edges.top < bounds.top + margin) {
|
||||
edges.offset(0f, bounds.top - edges.top)
|
||||
}
|
||||
if (edges.right > bounds.right - margin) {
|
||||
edges.offset(bounds.right - edges.right, 0f)
|
||||
}
|
||||
if (edges.bottom > bounds.bottom - margin) {
|
||||
edges.offset(0f, bounds.bottom - edges.bottom)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resulting x-position of the left edge of the crop window given the handle's position
|
||||
* and the image's bounding box and snap radius.
|
||||
*
|
||||
* @param left the position that the left edge is dragged to
|
||||
* @param bounds the bounding box of the image that is being notCropped
|
||||
* @param snapMargin the snap distance to the image edge (in pixels)
|
||||
*/
|
||||
private fun adjustLeft(
|
||||
rect: RectF,
|
||||
left: Float,
|
||||
bounds: RectF,
|
||||
snapMargin: Float
|
||||
) {
|
||||
var newLeft = left
|
||||
if (newLeft < 0) {
|
||||
newLeft /= 1.05f
|
||||
mTouchOffset.x -= newLeft / 1.1f
|
||||
}
|
||||
if (newLeft < bounds.left) {
|
||||
mTouchOffset.x -= (newLeft - bounds.left) / 2f
|
||||
}
|
||||
if (newLeft - bounds.left < snapMargin) {
|
||||
newLeft = bounds.left
|
||||
}
|
||||
|
||||
// Checks if the window is too small horizontally
|
||||
if (rect.right - newLeft < mMinCropWidth) {
|
||||
newLeft = rect.right - mMinCropWidth
|
||||
}
|
||||
|
||||
// Checks if the window is too large horizontally
|
||||
if (rect.right - newLeft > mMaxCropWidth) {
|
||||
newLeft = rect.right - mMaxCropWidth
|
||||
}
|
||||
if (newLeft - bounds.left < snapMargin) {
|
||||
newLeft = bounds.left
|
||||
}
|
||||
rect.left = newLeft
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resulting x-position of the right edge of the crop window given the handle's position
|
||||
* and the image's bounding box and snap radius.
|
||||
*
|
||||
* @param right the position that the right edge is dragged to
|
||||
* @param bounds the bounding box of the image that is being notCropped
|
||||
* @param viewWidth
|
||||
* @param snapMargin the snap distance to the image edge (in pixels)
|
||||
*/
|
||||
private fun adjustRight(
|
||||
rect: RectF,
|
||||
right: Float,
|
||||
bounds: RectF,
|
||||
viewWidth: Int,
|
||||
snapMargin: Float
|
||||
) {
|
||||
var newRight = right
|
||||
if (newRight > viewWidth) {
|
||||
newRight = viewWidth + (newRight - viewWidth) / 1.05f
|
||||
mTouchOffset.x -= (newRight - viewWidth) / 1.1f
|
||||
}
|
||||
if (newRight > bounds.right) {
|
||||
mTouchOffset.x -= (newRight - bounds.right) / 2f
|
||||
}
|
||||
|
||||
// If close to the edge
|
||||
if (bounds.right - newRight < snapMargin) {
|
||||
newRight = bounds.right
|
||||
}
|
||||
|
||||
// Checks if the window is too small horizontally
|
||||
if (newRight - rect.left < mMinCropWidth) {
|
||||
newRight = rect.left + mMinCropWidth
|
||||
}
|
||||
|
||||
// Checks if the window is too large horizontally
|
||||
if (newRight - rect.left > mMaxCropWidth) {
|
||||
newRight = rect.left + mMaxCropWidth
|
||||
}
|
||||
|
||||
// If close to the edge
|
||||
if (bounds.right - newRight < snapMargin) {
|
||||
newRight = bounds.right
|
||||
}
|
||||
rect.right = newRight
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resulting y-position of the top edge of the crop window given the handle's position and
|
||||
* the image's bounding box and snap radius.
|
||||
*
|
||||
* @param top the x-position that the top edge is dragged to
|
||||
* @param bounds the bounding box of the image that is being notCropped
|
||||
* @param snapMargin the snap distance to the image edge (in pixels)
|
||||
*/
|
||||
private fun adjustTop(
|
||||
rect: RectF,
|
||||
top: Float,
|
||||
bounds: RectF,
|
||||
snapMargin: Float
|
||||
) {
|
||||
var newTop = top
|
||||
if (newTop < 0) {
|
||||
newTop /= 1.05f
|
||||
mTouchOffset.y -= newTop / 1.1f
|
||||
}
|
||||
if (newTop < bounds.top) {
|
||||
mTouchOffset.y -= (newTop - bounds.top) / 2f
|
||||
}
|
||||
if (newTop - bounds.top < snapMargin) {
|
||||
newTop = bounds.top
|
||||
}
|
||||
|
||||
// Checks if the window is too small vertically
|
||||
if (rect.bottom - newTop < mMinCropHeight) {
|
||||
newTop = rect.bottom - mMinCropHeight
|
||||
}
|
||||
|
||||
// Checks if the window is too large vertically
|
||||
if (rect.bottom - newTop > mMaxCropHeight) {
|
||||
newTop = rect.bottom - mMaxCropHeight
|
||||
}
|
||||
if (newTop - bounds.top < snapMargin) {
|
||||
newTop = bounds.top
|
||||
}
|
||||
rect.top = newTop
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resulting y-position of the bottom edge of the crop window given the handle's position
|
||||
* and the image's bounding box and snap radius.
|
||||
*
|
||||
* @param bottom the position that the bottom edge is dragged to
|
||||
* @param bounds the bounding box of the image that is being notCropped
|
||||
* @param viewHeight
|
||||
* @param snapMargin the snap distance to the image edge (in pixels)
|
||||
*/
|
||||
private fun adjustBottom(
|
||||
rect: RectF,
|
||||
bottom: Float,
|
||||
bounds: RectF,
|
||||
viewHeight: Int,
|
||||
snapMargin: Float
|
||||
) {
|
||||
var newBottom = bottom
|
||||
if (newBottom > viewHeight) {
|
||||
newBottom = viewHeight + (newBottom - viewHeight) / 1.05f
|
||||
mTouchOffset.y -= (newBottom - viewHeight) / 1.1f
|
||||
}
|
||||
if (newBottom > bounds.bottom) {
|
||||
mTouchOffset.y -= (newBottom - bounds.bottom) / 2f
|
||||
}
|
||||
if (bounds.bottom - newBottom < snapMargin) {
|
||||
newBottom = bounds.bottom
|
||||
}
|
||||
|
||||
// Checks if the window is too small vertically
|
||||
if (newBottom - rect.top < mMinCropHeight) {
|
||||
newBottom = rect.top + mMinCropHeight
|
||||
}
|
||||
|
||||
// Checks if the window is too small vertically
|
||||
if (newBottom - rect.top > mMaxCropHeight) {
|
||||
newBottom = rect.top + mMaxCropHeight
|
||||
}
|
||||
if (bounds.bottom - newBottom < snapMargin) {
|
||||
newBottom = bounds.bottom
|
||||
}
|
||||
rect.bottom = newBottom
|
||||
}
|
||||
// endregion
|
||||
|
||||
/** The type of crop window move that is handled. */
|
||||
enum class Type {
|
||||
TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, LEFT, TOP, RIGHT, BOTTOM, CENTER
|
||||
}
|
||||
}
|
|
@ -1,41 +1,113 @@
|
|||
package org.pixeldroid.app.posts
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import kotlinx.coroutines.launch
|
||||
import org.pixeldroid.app.databinding.ActivityAlbumBinding
|
||||
import org.pixeldroid.app.utils.BaseActivity
|
||||
import org.pixeldroid.app.utils.api.objects.Attachment
|
||||
|
||||
class AlbumActivity : BaseActivity() {
|
||||
|
||||
class AlbumActivity : AppCompatActivity() {
|
||||
private val model: AlbumViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val binding = ActivityAlbumBinding.inflate(layoutInflater)
|
||||
|
||||
setContentView(binding.root)
|
||||
val mediaAttachments = intent.getSerializableExtra("images") as ArrayList<Attachment>
|
||||
val index = intent.getIntExtra("index", 0)
|
||||
binding.albumPager.adapter = AlbumViewPagerAdapter(mediaAttachments,
|
||||
sensitive = false,
|
||||
opened = true
|
||||
)
|
||||
binding.albumPager.currentItem = index
|
||||
|
||||
if(mediaAttachments.size == 1){
|
||||
binding.albumPager.adapter = AlbumViewPagerAdapter(
|
||||
model.uiState.value.mediaAttachments,
|
||||
sensitive = false,
|
||||
opened = true,
|
||||
//In the activity, we assume we want to show everything
|
||||
alwaysShowNsfw = true,
|
||||
clickCallback = ::clickCallback
|
||||
)
|
||||
|
||||
binding.albumPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) { model.positionSelected(position) }
|
||||
})
|
||||
|
||||
if (model.uiState.value.mediaAttachments.size == 1) {
|
||||
binding.albumPager.isUserInputEnabled = false
|
||||
}
|
||||
else if((mediaAttachments.size) > 1) {
|
||||
} else if ((model.uiState.value.mediaAttachments.size) > 1) {
|
||||
binding.postIndicator.setViewPager(binding.albumPager)
|
||||
binding.postIndicator.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.postIndicator.visibility = View.GONE
|
||||
}
|
||||
|
||||
|
||||
// Not really necessary because the ViewPager saves its state in onSaveInstanceState, but
|
||||
// it's good to stay consistent in case something gets out of sync
|
||||
binding.albumPager.setCurrentItem(model.uiState.value.index, false)
|
||||
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setDisplayShowTitleEnabled(false)
|
||||
supportActionBar?.setBackgroundDrawable(null)
|
||||
window.statusBarColor = ContextCompat.getColor(this,android.R.color.transparent)
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
model.uiState.collect { uiState ->
|
||||
binding.albumPager.currentItem = uiState.index
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
model.isActionBarHidden.collect { isActionBarHidden ->
|
||||
val windowInsetsController =
|
||||
WindowCompat.getInsetsController(this@AlbumActivity.window, binding.albumPager)
|
||||
if (isActionBarHidden) {
|
||||
// Configure the behavior of the hidden system bars
|
||||
windowInsetsController.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
// Hide both the status bar and the navigation bar
|
||||
supportActionBar?.hide()
|
||||
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
|
||||
binding.postIndicator.visibility = View.GONE
|
||||
} else {
|
||||
// Configure the behavior of the hidden system bars
|
||||
windowInsetsController.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
// Show both the status bar and the navigation bar
|
||||
supportActionBar?.show()
|
||||
windowInsetsController.show(WindowInsetsCompat.Type.systemBars())
|
||||
if ((model.uiState.value.mediaAttachments.size) > 1) {
|
||||
binding.postIndicator.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback passed to the AlbumViewPagerAdapter to signal a single click on the image
|
||||
*/
|
||||
private fun clickCallback(){
|
||||
model.barHide()
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
// Handle up arrow manually,
|
||||
// since "up" isn't defined for this activity
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
package org.pixeldroid.app.posts
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.pixeldroid.app.utils.api.objects.Attachment
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AlbumUiState(
|
||||
val mediaAttachments: ArrayList<Attachment> = arrayListOf(),
|
||||
val index: Int = 0,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class AlbumViewModel @Inject constructor(state: SavedStateHandle) : ViewModel() {
|
||||
companion object {
|
||||
const val ALBUM_IMAGES = "AlbumViewImages"
|
||||
const val ALBUM_INDEX = "AlbumViewIndex"
|
||||
}
|
||||
|
||||
private val _uiState: MutableStateFlow<AlbumUiState>
|
||||
private val _isActionBarHidden: MutableStateFlow<Boolean>
|
||||
|
||||
init {
|
||||
_uiState = MutableStateFlow(AlbumUiState(
|
||||
mediaAttachments = state[ALBUM_IMAGES] ?: ArrayList(),
|
||||
index = state[ALBUM_INDEX] ?: 0
|
||||
))
|
||||
_isActionBarHidden = MutableStateFlow(false)
|
||||
}
|
||||
|
||||
val uiState: StateFlow<AlbumUiState> = _uiState.asStateFlow()
|
||||
val isActionBarHidden: StateFlow<Boolean> = _isActionBarHidden
|
||||
|
||||
fun barHide() {
|
||||
_isActionBarHidden.update { !it }
|
||||
}
|
||||
|
||||
fun positionSelected(position: Int) {
|
||||
_uiState.update { it.copy(index = position) }
|
||||
}
|
||||
}
|
|
@ -11,6 +11,7 @@ import android.view.View
|
|||
import android.widget.TextView
|
||||
import androidx.core.text.toSpanned
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.utils.api.PixelfedAPI
|
||||
import org.pixeldroid.app.utils.api.objects.Account.Companion.openAccountFromId
|
||||
|
@ -106,7 +107,7 @@ fun parseHTMLText(
|
|||
override fun onClick(widget: View) {
|
||||
|
||||
// Retrieve the account for the given profile
|
||||
lifecycleScope.launchWhenCreated {
|
||||
lifecycleScope.launch {
|
||||
val api: PixelfedAPI = apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
openAccountFromId(accountId, api, context)
|
||||
}
|
||||
|
@ -130,7 +131,7 @@ fun parseHTMLText(
|
|||
}
|
||||
|
||||
|
||||
fun setTextViewFromISO8601(date: Instant, textView: TextView, absoluteTime: Boolean, context: Context) {
|
||||
fun setTextViewFromISO8601(date: Instant, textView: TextView, absoluteTime: Boolean) {
|
||||
val now = Date.from(Instant.now()).time
|
||||
|
||||
try {
|
||||
|
@ -140,7 +141,7 @@ fun setTextViewFromISO8601(date: Instant, textView: TextView, absoluteTime: Bool
|
|||
android.text.format.DateUtils.SECOND_IN_MILLIS,
|
||||
android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE).toString()
|
||||
|
||||
textView.text = if(absoluteTime) context.getString(R.string.posted_on).format(date)
|
||||
textView.text = if(absoluteTime) textView.context.getString(R.string.posted_on).format(date)
|
||||
else formattedDate
|
||||
|
||||
} catch (e: ParseException) {
|
||||
|
|
|
@ -14,9 +14,9 @@ import androidx.media2.common.MediaMetadata
|
|||
import androidx.media2.common.UriMediaItem
|
||||
import androidx.media2.player.MediaPlayer
|
||||
import org.pixeldroid.app.databinding.ActivityMediaviewerBinding
|
||||
import org.pixeldroid.app.utils.BaseThemedWithoutBarActivity
|
||||
import org.pixeldroid.app.utils.BaseActivity
|
||||
|
||||
class MediaViewerActivity : BaseThemedWithoutBarActivity() {
|
||||
class MediaViewerActivity : BaseActivity() {
|
||||
|
||||
private lateinit var mediaPlayer: MediaPlayer
|
||||
private lateinit var binding: ActivityMediaviewerBinding
|
||||
|
|
|
@ -88,19 +88,20 @@ class NestedScrollableHost(context: Context, attrs: AttributeSet? = null) :
|
|||
}
|
||||
val intent = Intent(context, AlbumActivity::class.java)
|
||||
|
||||
intent.putExtra("images", images)
|
||||
intent.putExtra("index", (child as ViewPager2).currentItem)
|
||||
intent.putExtra(AlbumViewModel.ALBUM_IMAGES, images)
|
||||
intent.putExtra(AlbumViewModel.ALBUM_INDEX, (child as ViewPager2).currentItem)
|
||||
|
||||
context.startActivity(intent)
|
||||
|
||||
return super.onSingleTapConfirmed(e)
|
||||
}
|
||||
override fun onScroll(
|
||||
e1: MotionEvent,
|
||||
e1: MotionEvent?,
|
||||
e2: MotionEvent,
|
||||
distanceX: Float,
|
||||
distanceY: Float
|
||||
): Boolean {
|
||||
if (e1 == null) return false
|
||||
val orientation = parentViewPager?.orientation ?: return true
|
||||
|
||||
val dx = e2.x - e1.x
|
||||
|
|
|
@ -5,34 +5,38 @@ import android.util.Log
|
|||
import android.view.View
|
||||
import android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.ActivityPostBinding
|
||||
import org.pixeldroid.app.posts.feeds.uncachedFeeds.comments.CommentFragment
|
||||
import org.pixeldroid.app.posts.feeds.uncachedFeeds.comments.CommentFragment.Companion.COMMENT_DOMAIN
|
||||
import org.pixeldroid.app.posts.feeds.uncachedFeeds.comments.CommentFragment.Companion.COMMENT_STATUS_ID
|
||||
import org.pixeldroid.app.utils.BaseThemedWithBarActivity
|
||||
import org.pixeldroid.app.utils.BaseActivity
|
||||
import org.pixeldroid.app.utils.api.PixelfedAPI
|
||||
import org.pixeldroid.app.utils.api.objects.Status
|
||||
import org.pixeldroid.app.utils.api.objects.Status.Companion.POST_COMMENT_TAG
|
||||
import org.pixeldroid.app.utils.api.objects.Status.Companion.POST_TAG
|
||||
import org.pixeldroid.app.utils.api.objects.Status.Companion.VIEW_COMMENTS_TAG
|
||||
import org.pixeldroid.app.utils.displayDimensionsInPx
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
|
||||
class PostActivity : BaseThemedWithBarActivity() {
|
||||
class PostActivity : BaseActivity() {
|
||||
private lateinit var binding: ActivityPostBinding
|
||||
|
||||
private var commentFragment = CommentFragment()
|
||||
private lateinit var commentFragment: CommentFragment
|
||||
|
||||
private lateinit var status: Status
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityPostBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
commentFragment = CommentFragment(binding.swipeRefreshLayout)
|
||||
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.topBar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
status = intent.getSerializableExtra(POST_TAG) as Status
|
||||
|
@ -45,7 +49,10 @@ class PostActivity : BaseThemedWithBarActivity() {
|
|||
|
||||
val holder = StatusViewHolder(binding.postFragmentSingle)
|
||||
|
||||
holder.bind(status, apiHolder, db, lifecycleScope, displayDimensionsInPx(), isActivity = true)
|
||||
holder.bind(
|
||||
status, apiHolder, db, lifecycleScope, displayDimensionsInPx(),
|
||||
requestPermissionDownloadPic, isActivity = true
|
||||
)
|
||||
|
||||
activateCommenter()
|
||||
initCommentsFragment(domain = user?.instance_uri.orEmpty())
|
||||
|
@ -62,6 +69,17 @@ class PostActivity : BaseThemedWithBarActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
private val requestPermissionDownloadPic =
|
||||
registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted: Boolean ->
|
||||
if (!isGranted) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setMessage(R.string.write_permission_download_pic)
|
||||
.setNegativeButton(android.R.string.ok) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
private fun activateCommenter() {
|
||||
//Activate commenter
|
||||
binding.submitComment.setOnClickListener {
|
||||
|
@ -91,6 +109,11 @@ class PostActivity : BaseThemedWithBarActivity() {
|
|||
|
||||
supportFragmentManager.beginTransaction()
|
||||
.add(R.id.commentFragment, commentFragment).commit()
|
||||
|
||||
binding.swipeRefreshLayout.setOnRefreshListener {
|
||||
commentFragment.adapter.refresh()
|
||||
commentFragment.adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun postComment(
|
||||
|
@ -100,7 +123,7 @@ class PostActivity : BaseThemedWithBarActivity() {
|
|||
val nonNullText = textIn.toString()
|
||||
status.id.let {
|
||||
try {
|
||||
val response = api.postStatus(nonNullText, it)
|
||||
api.postStatus(nonNullText, it)
|
||||
binding.commentIn.visibility = View.GONE
|
||||
|
||||
//Reload to add the comment to the comment section
|
||||
|
@ -111,18 +134,12 @@ class PostActivity : BaseThemedWithBarActivity() {
|
|||
binding.root.context.getString(R.string.comment_posted).format(textIn),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} catch (exception: IOException) {
|
||||
} catch (exception: Exception) {
|
||||
Log.e("COMMENT ERROR", exception.toString())
|
||||
Toast.makeText(
|
||||
binding.root.context, binding.root.context.getString(R.string.comment_error),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} catch (exception: HttpException) {
|
||||
Toast.makeText(
|
||||
binding.root.context, binding.root.context.getString(R.string.comment_error),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
Log.e("ERROR_CODE", exception.code().toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,12 +5,10 @@ import android.view.View
|
|||
import androidx.lifecycle.lifecycleScope
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.ActivityReportBinding
|
||||
import org.pixeldroid.app.utils.BaseThemedWithBarActivity
|
||||
import org.pixeldroid.app.utils.BaseActivity
|
||||
import org.pixeldroid.app.utils.api.objects.Status
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
|
||||
class ReportActivity : BaseThemedWithBarActivity() {
|
||||
class ReportActivity : BaseActivity() {
|
||||
|
||||
private lateinit var binding: ActivityReportBinding
|
||||
|
||||
|
@ -18,9 +16,9 @@ class ReportActivity : BaseThemedWithBarActivity() {
|
|||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityReportBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.topBar)
|
||||
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setTitle(R.string.report)
|
||||
|
||||
val status = intent.getSerializableExtra(Status.POST_TAG) as Status?
|
||||
|
||||
|
@ -44,9 +42,7 @@ class ReportActivity : BaseThemedWithBarActivity() {
|
|||
)
|
||||
|
||||
reportStatus(true)
|
||||
} catch (exception: IOException) {
|
||||
reportStatus(false)
|
||||
} catch (exception: HttpException) {
|
||||
} catch (exception: Exception) {
|
||||
reportStatus(false)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
package org.pixeldroid.app.posts
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager.PERMISSION_DENIED
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.AnimatedVectorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Looper
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
|
@ -15,12 +16,10 @@ import android.view.Menu
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
|
||||
import androidx.viewbinding.ViewBinding
|
||||
|
@ -31,16 +30,18 @@ import com.bumptech.glide.request.target.CustomViewTarget
|
|||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.karumi.dexter.Dexter
|
||||
import com.karumi.dexter.listener.PermissionDeniedResponse
|
||||
import com.karumi.dexter.listener.PermissionGrantedResponse
|
||||
import com.karumi.dexter.listener.single.BasePermissionListener
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.*
|
||||
import okio.BufferedSink
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.AlbumImageViewBinding
|
||||
import org.pixeldroid.app.databinding.OpenedAlbumBinding
|
||||
import org.pixeldroid.app.databinding.PostFragmentBinding
|
||||
import org.pixeldroid.app.postCreation.PostCreationActivity
|
||||
import org.pixeldroid.app.posts.MediaViewerActivity.Companion.openActivity
|
||||
import org.pixeldroid.app.utils.BlurHashDecoder
|
||||
import org.pixeldroid.app.utils.api.PixelfedAPI
|
||||
|
@ -55,6 +56,7 @@ import org.pixeldroid.app.utils.setProfileImageFromURL
|
|||
import retrofit2.HttpException
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
|
@ -65,7 +67,11 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
|
|||
|
||||
private var status: Status? = null
|
||||
|
||||
fun bind(status: Status?, pixelfedAPI: PixelfedAPIHolder, db: AppDatabase, lifecycleScope: LifecycleCoroutineScope, displayDimensionsInPx: Pair<Int, Int>, isActivity: Boolean = false) {
|
||||
fun bind(
|
||||
status: Status?, pixelfedAPI: PixelfedAPIHolder, db: AppDatabase,
|
||||
lifecycleScope: LifecycleCoroutineScope, displayDimensionsInPx: Pair<Int, Int>,
|
||||
requestPermissionDownloadPic: ActivityResultLauncher<String>, isActivity: Boolean = false,
|
||||
) {
|
||||
|
||||
this.itemView.visibility = View.VISIBLE
|
||||
this.status = status
|
||||
|
@ -94,7 +100,7 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
|
|||
|
||||
setupPost(picRequest, user.instance_uri, isActivity)
|
||||
|
||||
activateButtons(pixelfedAPI, db, lifecycleScope, isActivity)
|
||||
activateButtons(pixelfedAPI, db, lifecycleScope, isActivity, requestPermissionDownloadPic)
|
||||
|
||||
}
|
||||
|
||||
|
@ -129,8 +135,7 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
|
|||
setTextViewFromISO8601(
|
||||
status?.created_at!!,
|
||||
binding.postDate,
|
||||
isActivity,
|
||||
binding.root.context
|
||||
isActivity
|
||||
)
|
||||
|
||||
binding.postDomain.text = status?.getStatusDomain(domain, binding.postDomain.context)
|
||||
|
@ -156,12 +161,15 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
|
|||
binding: PostFragmentBinding,
|
||||
request: RequestBuilder<Drawable>,
|
||||
) {
|
||||
val alwaysShowNsfw =
|
||||
PreferenceManager.getDefaultSharedPreferences(binding.root.context.applicationContext)
|
||||
.getBoolean("always_show_nsfw", false)
|
||||
|
||||
// Standard layout
|
||||
binding.postPager.visibility = View.VISIBLE
|
||||
|
||||
//Attach the given tabs to the view pager
|
||||
binding.postPager.adapter = AlbumViewPagerAdapter(status?.media_attachments ?: emptyList(), status?.sensitive, false)
|
||||
binding.postPager.adapter = AlbumViewPagerAdapter(status?.media_attachments ?: emptyList(), status?.sensitive, false, alwaysShowNsfw)
|
||||
|
||||
if((status?.media_attachments?.size ?: 0) > 1) {
|
||||
binding.postIndicator.setViewPager(binding.postPager)
|
||||
|
@ -170,7 +178,7 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
|
|||
binding.postIndicator.visibility = View.GONE
|
||||
}
|
||||
|
||||
if (status?.sensitive == true) {
|
||||
if (status?.sensitive == true && !alwaysShowNsfw) {
|
||||
setupSensitiveLayout()
|
||||
} else {
|
||||
// GONE is the default, but have to set it again because of how RecyclerViews work
|
||||
|
@ -220,6 +228,7 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
|
|||
db: AppDatabase,
|
||||
lifecycleScope: LifecycleCoroutineScope,
|
||||
isActivity: Boolean,
|
||||
requestPermissionDownloadPic: ActivityResultLauncher<String>,
|
||||
){
|
||||
//Set the special HTML text
|
||||
setDescription(apiHolder, lifecycleScope)
|
||||
|
@ -249,7 +258,7 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
|
|||
|
||||
showComments(lifecycleScope, isActivity)
|
||||
|
||||
activateMoreButton(apiHolder, db, lifecycleScope)
|
||||
activateMoreButton(apiHolder, db, lifecycleScope, requestPermissionDownloadPic)
|
||||
}
|
||||
|
||||
private fun activateReblogger(
|
||||
|
@ -289,10 +298,7 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
|
|||
//Update shown share count
|
||||
binding.nshares.text = resp.getNShares(binding.root.context)
|
||||
binding.reblogger.isChecked = resp.reblogged!!
|
||||
} catch (exception: HttpException) {
|
||||
Log.e("RESPONSE_CODE", exception.code().toString())
|
||||
binding.reblogger.isChecked = false
|
||||
} catch (exception: IOException) {
|
||||
} catch (exception: Exception) {
|
||||
Log.e("REBLOG ERROR", exception.toString())
|
||||
binding.reblogger.isChecked = false
|
||||
}
|
||||
|
@ -311,7 +317,7 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
|
|||
} catch (exception: HttpException) {
|
||||
Log.e("RESPONSE_CODE", exception.code().toString())
|
||||
binding.reblogger.isChecked = true
|
||||
} catch (exception: IOException) {
|
||||
} catch (exception: Exception) {
|
||||
Log.e("REBLOG ERROR", exception.toString())
|
||||
binding.reblogger.isChecked = true
|
||||
}
|
||||
|
@ -322,7 +328,7 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
|
|||
//Call the api function
|
||||
status?.id?.let { id ->
|
||||
try {
|
||||
if(bookmarked) {
|
||||
if (bookmarked) {
|
||||
api.bookmarkStatus(id)
|
||||
} else {
|
||||
api.undoBookmarkStatus(id)
|
||||
|
@ -337,7 +343,10 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
|
|||
} catch (exception: HttpException) {
|
||||
Toast.makeText(
|
||||
binding.root.context,
|
||||
binding.root.context.getString(R.string.bookmark_post_failed_error, exception.code()),
|
||||
binding.root.context.getString(
|
||||
R.string.bookmark_post_failed_error,
|
||||
exception.code()
|
||||
),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} catch (exception: IOException) {
|
||||
|
@ -351,7 +360,12 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
|
|||
return null
|
||||
}
|
||||
|
||||
private fun activateMoreButton(apiHolder: PixelfedAPIHolder, db: AppDatabase, lifecycleScope: LifecycleCoroutineScope){
|
||||
private fun activateMoreButton(
|
||||
apiHolder: PixelfedAPIHolder,
|
||||
db: AppDatabase,
|
||||
lifecycleScope: LifecycleCoroutineScope,
|
||||
requestPermissionDownloadPic: ActivityResultLauncher<String>,
|
||||
){
|
||||
var bookmarked: Boolean? = null
|
||||
binding.statusMore.setOnClickListener {
|
||||
PopupMenu(it.context, it).apply {
|
||||
|
@ -389,89 +403,46 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
|
|||
true
|
||||
}
|
||||
R.id.post_more_menu_save_to_gallery -> {
|
||||
Dexter.withContext(binding.root.context)
|
||||
.withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
.withListener(object : BasePermissionListener() {
|
||||
override fun onPermissionDenied(p0: PermissionDeniedResponse?) {
|
||||
Toast.makeText(
|
||||
binding.root.context,
|
||||
binding.root.context.getString(R.string.write_permission_download_pic),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
override fun onPermissionGranted(p0: PermissionGrantedResponse?) {
|
||||
status?.downloadImage(
|
||||
binding.root.context,
|
||||
status?.media_attachments?.getOrNull(binding.postPager.currentItem)?.url
|
||||
?: "",
|
||||
binding.root
|
||||
)
|
||||
}
|
||||
}).check()
|
||||
// Check permissions on old Android versions: on new versions it is not
|
||||
// needed when storing a file.
|
||||
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && ContextCompat.checkSelfPermission(binding.root.context, android.Manifest.permission.WRITE_EXTERNAL_STORAGE) == PERMISSION_DENIED) {
|
||||
requestPermissionDownloadPic.launch(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
} else {
|
||||
status?.downloadImage(
|
||||
binding.root.context,
|
||||
status?.media_attachments?.getOrNull(binding.postPager.currentItem)?.url
|
||||
?: "",
|
||||
binding.root
|
||||
)
|
||||
}
|
||||
true
|
||||
}
|
||||
R.id.post_more_menu_share_picture -> {
|
||||
Dexter.withContext(binding.root.context)
|
||||
.withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
.withListener(object : BasePermissionListener() {
|
||||
override fun onPermissionDenied(p0: PermissionDeniedResponse?) {
|
||||
Toast.makeText(
|
||||
binding.root.context,
|
||||
binding.root.context.getString(R.string.write_permission_share_pic),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
override fun onPermissionGranted(p0: PermissionGrantedResponse?) {
|
||||
status?.downloadImage(
|
||||
binding.root.context,
|
||||
status?.media_attachments?.getOrNull(binding.postPager.currentItem)?.url
|
||||
?: "",
|
||||
binding.root,
|
||||
share = true,
|
||||
)
|
||||
}
|
||||
}).check()
|
||||
R.id.post_more_menu_share_picture -> {
|
||||
status?.downloadImage(
|
||||
binding.root.context,
|
||||
status?.media_attachments?.getOrNull(binding.postPager.currentItem)?.url
|
||||
?: "",
|
||||
binding.root,
|
||||
share = true,
|
||||
)
|
||||
true
|
||||
}
|
||||
R.id.post_more_menu_delete -> {
|
||||
val builder = AlertDialog.Builder(binding.root.context)
|
||||
builder.apply {
|
||||
setMessage(R.string.delete_dialog)
|
||||
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
MaterialAlertDialogBuilder(binding.root.context)
|
||||
.setMessage(R.string.delete_dialog)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
|
||||
lifecycleScope.launch {
|
||||
val user = db.userDao().getActiveUser()!!
|
||||
status?.id?.let { id ->
|
||||
db.homePostDao().delete(id, user.user_id, user.instance_uri)
|
||||
db.publicPostDao().delete(id, user.user_id, user.instance_uri)
|
||||
try {
|
||||
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
api.deleteStatus(id)
|
||||
binding.root.visibility = View.GONE
|
||||
} catch (exception: HttpException) {
|
||||
Toast.makeText(
|
||||
binding.root.context,
|
||||
binding.root.context.getString(R.string.delete_post_failed_error, exception.code()),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} catch (exception: IOException) {
|
||||
Toast.makeText(
|
||||
binding.root.context,
|
||||
binding.root.context.getString(R.string.delete_post_failed_io_except),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
deletePost(apiHolder.api ?: apiHolder.setToCurrentUser(), db)
|
||||
}
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
show()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
|
||||
true
|
||||
}
|
||||
R.id.post_more_menu_redraft -> launchRedraftDialog(lifecycleScope, apiHolder, db)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
@ -496,6 +467,165 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
|
|||
}
|
||||
}
|
||||
|
||||
private fun launchRedraftDialog(
|
||||
lifecycleScope: LifecycleCoroutineScope,
|
||||
apiHolder: PixelfedAPIHolder,
|
||||
db: AppDatabase
|
||||
): Boolean {
|
||||
MaterialAlertDialogBuilder(binding.root.context).apply {
|
||||
setMessage(R.string.redraft_dialog_launch)
|
||||
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
// Get descriptions and images from original post
|
||||
val postDescription = status?.content ?: ""
|
||||
val postAttachments =
|
||||
status?.media_attachments!! // TODO Catch possible exception from !! (?)
|
||||
val postNSFW = status?.sensitive
|
||||
|
||||
val imageUriStrings = postAttachments.map { postAttachment ->
|
||||
postAttachment.url ?: ""
|
||||
}
|
||||
val imageNames = imageUriStrings.map { imageUriString ->
|
||||
Uri.parse(imageUriString).lastPathSegment.toString()
|
||||
}
|
||||
val downloadedFiles = imageNames.map { imageName ->
|
||||
File(context.cacheDir, imageName)
|
||||
}
|
||||
val imageDescriptions = postAttachments.map { postAttachment ->
|
||||
fromHtml(
|
||||
postAttachment.description ?: ""
|
||||
).toString()
|
||||
}
|
||||
val downloadRequests: List<Request> =
|
||||
imageUriStrings.map { imageUriString ->
|
||||
Request.Builder().url(imageUriString).build()
|
||||
}
|
||||
|
||||
val imageUris = downloadedFiles.map { downloadedFile ->
|
||||
Uri.fromFile(downloadedFile)
|
||||
}
|
||||
|
||||
val counter = AtomicInteger(0)
|
||||
|
||||
// Define callback function for after downloading the images
|
||||
fun continuation() {
|
||||
// Wait for all outstanding downloads to finish
|
||||
if (counter.incrementAndGet() == imageUris.size) {
|
||||
if (allFilesExist(imageNames)) {
|
||||
// Delete original post
|
||||
lifecycleScope.launch {
|
||||
deletePost(
|
||||
apiHolder.api ?: apiHolder.setToCurrentUser(), db
|
||||
)
|
||||
}
|
||||
|
||||
val counterInt = counter.get()
|
||||
Toast.makeText(
|
||||
binding.root.context,
|
||||
binding.root.context.resources.getQuantityString(
|
||||
R.plurals.items_load_success, counterInt, counterInt
|
||||
),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
|
||||
// Create new post creation activity
|
||||
val intent = PostCreationActivity.intentForUris(context, imageUris).apply {
|
||||
putExtra(
|
||||
PostCreationActivity.PICTURE_DESCRIPTIONS,
|
||||
ArrayList(imageDescriptions)
|
||||
)
|
||||
// Pass post description of existing post to new post creation activity
|
||||
putExtra(
|
||||
PostCreationActivity.POST_DESCRIPTION,
|
||||
fromHtml(postDescription).toString()
|
||||
)
|
||||
if (imageNames.isNotEmpty()) {
|
||||
putExtra(
|
||||
PostCreationActivity.TEMP_FILES,
|
||||
imageNames.toTypedArray()
|
||||
)
|
||||
}
|
||||
putExtra(PostCreationActivity.POST_REDRAFT, true)
|
||||
putExtra(PostCreationActivity.POST_NSFW, postNSFW)
|
||||
}
|
||||
|
||||
// Launch post creation activity
|
||||
binding.root.context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!allFilesExist(imageNames)) {
|
||||
// Track download progress
|
||||
Toast.makeText(
|
||||
binding.root.context,
|
||||
binding.root.context.getString(R.string.image_download_downloading),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
// Iterate through all pictures of the original post
|
||||
downloadRequests.zip(downloadedFiles)
|
||||
.forEach { (downloadRequest, downloadedFile) ->
|
||||
// Check whether image is in cache directory already (maybe rather do so using Glide in the future?)
|
||||
if (!downloadedFile.exists()) {
|
||||
OkHttpClient().newCall(downloadRequest)
|
||||
.enqueue(object : Callback {
|
||||
override fun onFailure(
|
||||
call: Call,
|
||||
e: IOException,
|
||||
) {
|
||||
Looper.prepare()
|
||||
downloadedFile.delete()
|
||||
Toast.makeText(
|
||||
binding.root.context,
|
||||
binding.root.context.getString(
|
||||
R.string.redraft_post_failed_io_except
|
||||
),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun onResponse(
|
||||
call: Call,
|
||||
response: Response,
|
||||
) {
|
||||
val sink: BufferedSink =
|
||||
downloadedFile.sink().buffer()
|
||||
sink.writeAll(response.body!!.source())
|
||||
sink.close()
|
||||
Looper.prepare()
|
||||
continuation()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
continuation()
|
||||
}
|
||||
}
|
||||
} catch (exception: HttpException) {
|
||||
Toast.makeText(
|
||||
binding.root.context, binding.root.context.getString(
|
||||
R.string.redraft_post_failed_error, exception.code()
|
||||
), Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} catch (exception: IOException) {
|
||||
Toast.makeText(
|
||||
binding.root.context,
|
||||
binding.root.context.getString(R.string.redraft_post_failed_io_except),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
show()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun activateLiker(
|
||||
apiHolder: PixelfedAPIHolder,
|
||||
isLiked: Boolean,
|
||||
|
@ -546,6 +676,7 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
|
|||
status?.media_attachments?.let { binding.postPagerHost.images = ArrayList(it) }
|
||||
|
||||
}
|
||||
|
||||
private fun ImageView.animateView() {
|
||||
visibility = View.VISIBLE
|
||||
when (val drawable = drawable) {
|
||||
|
@ -568,12 +699,12 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
|
|||
//Update shown like count and internal like toggle
|
||||
binding.nlikes.text = resp.getNLikes(binding.root.context)
|
||||
binding.liker.isChecked = resp.favourited ?: false
|
||||
} catch (exception: IOException) {
|
||||
Log.e("LIKE ERROR", exception.toString())
|
||||
binding.liker.isChecked = false
|
||||
} catch (exception: HttpException) {
|
||||
Log.e("RESPONSE_CODE", exception.code().toString())
|
||||
binding.liker.isChecked = false
|
||||
} catch (exception: Exception) {
|
||||
Log.e("LIKE ERROR", exception.toString())
|
||||
binding.liker.isChecked = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -588,12 +719,12 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
|
|||
//Update shown like count and internal like toggle
|
||||
binding.nlikes.text = resp.getNLikes(binding.root.context)
|
||||
binding.liker.isChecked = resp.favourited ?: false
|
||||
} catch (exception: IOException) {
|
||||
Log.e("UNLIKE ERROR", exception.toString())
|
||||
binding.liker.isChecked = true
|
||||
} catch (exception: HttpException) {
|
||||
Log.e("RESPONSE_CODE", exception.code().toString())
|
||||
binding.liker.isChecked = true
|
||||
} catch (exception: Exception) {
|
||||
Log.e("UNLIKE ERROR", exception.toString())
|
||||
binding.liker.isChecked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -627,8 +758,35 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun deletePost(api: PixelfedAPI, db: AppDatabase) {
|
||||
val user = db.userDao().getActiveUser()!!
|
||||
status?.id?.let { id ->
|
||||
db.homePostDao().delete(id, user.user_id, user.instance_uri)
|
||||
db.publicPostDao().delete(id, user.user_id, user.instance_uri)
|
||||
try {
|
||||
api.deleteStatus(id)
|
||||
binding.root.visibility = View.GONE
|
||||
} catch (exception: HttpException) {
|
||||
Toast.makeText(
|
||||
binding.root.context,
|
||||
binding.root.context.getString(R.string.delete_post_failed_error, exception.code()),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} catch (exception: IOException) {
|
||||
Toast.makeText(
|
||||
binding.root.context,
|
||||
binding.root.context.getString(R.string.delete_post_failed_io_except),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun allFilesExist(listOfNames: List<String>): Boolean {
|
||||
return listOfNames.all {
|
||||
File(binding.root.context.cacheDir, it).exists()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(parent: ViewGroup): StatusViewHolder {
|
||||
|
@ -642,18 +800,16 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
|
|||
|
||||
class AlbumViewPagerAdapter(
|
||||
private val media_attachments: List<Attachment>, private var sensitive: Boolean?,
|
||||
private val opened: Boolean,
|
||||
) :
|
||||
RecyclerView.Adapter<AlbumViewPagerAdapter.ViewHolder>() {
|
||||
|
||||
private var isActionBarHidden: Boolean = false
|
||||
private val opened: Boolean, private val alwaysShowNsfw: Boolean,
|
||||
private val clickCallback: (() -> Unit)? = null
|
||||
) : RecyclerView.Adapter<AlbumViewPagerAdapter.ViewHolder>() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
return if(!opened) ViewHolderClosed(AlbumImageViewBinding.inflate(
|
||||
LayoutInflater.from(parent.context), parent, false
|
||||
)) else ViewHolderOpen(OpenedAlbumBinding.inflate(
|
||||
LayoutInflater.from(parent.context), parent, false
|
||||
))
|
||||
), clickCallback!!)
|
||||
}
|
||||
|
||||
override fun getItemCount() = media_attachments.size
|
||||
|
@ -668,7 +824,7 @@ class AlbumViewPagerAdapter(
|
|||
meta?.original?.height
|
||||
)
|
||||
}
|
||||
if (sensitive == false) {
|
||||
if (sensitive == false || alwaysShowNsfw) {
|
||||
val imageUrl = if(video) preview_url else url
|
||||
if(opened){
|
||||
Glide.with(holder.binding.root)
|
||||
|
@ -684,24 +840,6 @@ class AlbumViewPagerAdapter(
|
|||
setDoubleTapZoomDpi(240)
|
||||
resetScaleAndCenter()
|
||||
}
|
||||
holder.image.setOnClickListener {
|
||||
val windowInsetsController = WindowCompat.getInsetsController((it.context as Activity).window, it)
|
||||
// Configure the behavior of the hidden system bars
|
||||
if (isActionBarHidden) {
|
||||
windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
// Hide both the status bar and the navigation bar
|
||||
(it.context as AppCompatActivity).supportActionBar?.show()
|
||||
windowInsetsController.show(WindowInsetsCompat.Type.systemBars())
|
||||
isActionBarHidden = false
|
||||
} else {
|
||||
// Configure the behavior of the hidden system bars
|
||||
windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
// Hide both the status bar and the navigation bar
|
||||
(it.context as AppCompatActivity).supportActionBar?.hide()
|
||||
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
|
||||
isActionBarHidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
else Glide.with(holder.binding.root)
|
||||
.asDrawable().fitCenter()
|
||||
|
@ -737,6 +875,7 @@ class AlbumViewPagerAdapter(
|
|||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun uncensor(){
|
||||
sensitive = false
|
||||
notifyDataSetChanged()
|
||||
|
@ -746,12 +885,16 @@ class AlbumViewPagerAdapter(
|
|||
abstract val videoPlayButton: ImageView
|
||||
}
|
||||
|
||||
class ViewHolderOpen(override val binding: OpenedAlbumBinding) : ViewHolder(binding) {
|
||||
class ViewHolderOpen(override val binding: OpenedAlbumBinding, clickCallback: () -> Unit) : ViewHolder(binding) {
|
||||
override val image: SubsamplingScaleImageView = binding.imageImageView
|
||||
override val videoPlayButton: ImageView = binding.videoPlayButton
|
||||
|
||||
init {
|
||||
image.setOnClickListener { clickCallback() }
|
||||
}
|
||||
}
|
||||
class ViewHolderClosed(override val binding: AlbumImageViewBinding) : ViewHolder(binding) {
|
||||
override val image: ImageView = binding.imageImageView
|
||||
override val videoPlayButton: ImageView = binding.videoPlayButton
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,19 +1,21 @@
|
|||
package org.pixeldroid.app.posts.feeds
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ProgressBar
|
||||
import androidx.constraintlayout.motion.widget.MotionLayout
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import androidx.paging.CombinedLoadStates
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.LoadStateAdapter
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.ConcatAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -21,6 +23,7 @@ import org.pixeldroid.app.R
|
|||
import org.pixeldroid.app.databinding.ErrorLayoutBinding
|
||||
import org.pixeldroid.app.databinding.LoadStateFooterViewItemBinding
|
||||
import org.pixeldroid.app.posts.feeds.uncachedFeeds.FeedViewModel
|
||||
import org.pixeldroid.app.stories.StoriesAdapter
|
||||
import org.pixeldroid.app.utils.api.objects.FeedContent
|
||||
import org.pixeldroid.app.utils.api.objects.Status
|
||||
import retrofit2.HttpException
|
||||
|
@ -31,8 +34,7 @@ import retrofit2.HttpException
|
|||
private fun showError(
|
||||
errorText: String, show: Boolean = true,
|
||||
motionLayout: MotionLayout,
|
||||
errorLayout: ErrorLayoutBinding,
|
||||
progressBar: ProgressBar){
|
||||
errorLayout: ErrorLayoutBinding){
|
||||
|
||||
if(show) {
|
||||
motionLayout.transitionToEnd()
|
||||
|
@ -40,7 +42,6 @@ private fun showError(
|
|||
} else if(motionLayout.progress == 1F) {
|
||||
motionLayout.transitionToStart()
|
||||
}
|
||||
progressBar.visibility = View.GONE
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -51,14 +52,29 @@ private fun showError(
|
|||
internal fun <T: Any> initAdapter(
|
||||
progressBar: ProgressBar, swipeRefreshLayout: SwipeRefreshLayout,
|
||||
recyclerView: RecyclerView, motionLayout: MotionLayout, errorLayout: ErrorLayoutBinding,
|
||||
adapter: PagingDataAdapter<T, RecyclerView.ViewHolder>) {
|
||||
adapter: PagingDataAdapter<T, RecyclerView.ViewHolder>,
|
||||
header: StoriesAdapter? = null
|
||||
) {
|
||||
|
||||
recyclerView.adapter = adapter.withLoadStateFooter(
|
||||
footer = ReposLoadStateAdapter { adapter.retry() }
|
||||
|
||||
val footer = ReposLoadStateAdapter { adapter.retry() }
|
||||
|
||||
adapter.addLoadStateListener { loadStates: CombinedLoadStates ->
|
||||
footer.loadState = loadStates.append
|
||||
}
|
||||
|
||||
recyclerView.adapter = ConcatAdapter(
|
||||
*listOfNotNull(
|
||||
header, // need to filter it if null
|
||||
adapter,
|
||||
footer
|
||||
).toTypedArray()
|
||||
)
|
||||
|
||||
swipeRefreshLayout.setOnRefreshListener {
|
||||
adapter.refresh()
|
||||
adapter.notifyDataSetChanged()
|
||||
header?.refreshStories()
|
||||
}
|
||||
|
||||
adapter.addLoadStateListener { loadState ->
|
||||
|
@ -83,11 +99,23 @@ internal fun <T: Any> initAdapter(
|
|||
?: loadState.append as? LoadState.Error
|
||||
?: loadState.prepend as? LoadState.Error
|
||||
?: loadState.refresh as? LoadState.Error
|
||||
|
||||
if(errorState?.error is CancellationException){
|
||||
return@addLoadStateListener
|
||||
}
|
||||
|
||||
errorState?.let {
|
||||
val error: String = (it.error as? HttpException)?.response()?.errorBody()?.string()?.ifEmpty { null }?.let { s ->
|
||||
Gson().fromJson(s, org.pixeldroid.app.utils.api.objects.Error::class.java)?.error?.ifBlank { null }
|
||||
try {
|
||||
Gson().fromJson(s, org.pixeldroid.app.utils.api.objects.Error::class.java)?.error?.ifBlank { null }
|
||||
} catch (exception: Exception) {
|
||||
errorLayout.root.context.getString(
|
||||
R.string.unknown_error_in_error,
|
||||
it.error.localizedMessage.orEmpty()
|
||||
)
|
||||
}
|
||||
} ?: it.error.localizedMessage.orEmpty()
|
||||
showError(motionLayout = motionLayout, errorLayout = errorLayout, errorText = error, progressBar = progressBar)
|
||||
showError(motionLayout = motionLayout, errorLayout = errorLayout, errorText = error)
|
||||
}
|
||||
|
||||
// If the state is not an error, hide the error layout, or show message that the feed is empty
|
||||
|
@ -100,10 +128,9 @@ internal fun <T: Any> initAdapter(
|
|||
showError(
|
||||
motionLayout = motionLayout, errorLayout = errorLayout,
|
||||
errorText = errorLayout.root.context.getString(R.string.empty_feed),
|
||||
progressBar = progressBar
|
||||
)
|
||||
} else {
|
||||
showError(motionLayout = motionLayout, errorLayout = errorLayout, show = false, errorText = "", progressBar = progressBar)
|
||||
showError(motionLayout = motionLayout, errorLayout = errorLayout, show = false, errorText = "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -140,6 +167,8 @@ class ReposLoadStateAdapter(
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* [RecyclerView.ViewHolder] that is shown at the end of the feed to indicate loading or errors
|
||||
* in the loading of appending values.
|
||||
|
|
|
@ -18,8 +18,10 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy
|
|||
import kotlinx.coroutines.flow.filter
|
||||
import org.pixeldroid.app.databinding.FragmentFeedBinding
|
||||
import org.pixeldroid.app.posts.feeds.initAdapter
|
||||
import org.pixeldroid.app.stories.StoriesAdapter
|
||||
import org.pixeldroid.app.utils.BaseFragment
|
||||
import org.pixeldroid.app.utils.api.objects.FeedContentDatabase
|
||||
import org.pixeldroid.app.utils.bindingLifecycleAware
|
||||
import org.pixeldroid.app.utils.db.AppDatabase
|
||||
import org.pixeldroid.app.utils.db.dao.feedContent.FeedContentDao
|
||||
import org.pixeldroid.app.utils.limitedLengthSmoothScrollToPosition
|
||||
|
@ -31,8 +33,9 @@ open class CachedFeedFragment<T: FeedContentDatabase> : BaseFragment() {
|
|||
|
||||
internal lateinit var viewModel: FeedViewModel<T>
|
||||
internal lateinit var adapter: PagingDataAdapter<T, RecyclerView.ViewHolder>
|
||||
internal var headerAdapter: StoriesAdapter? = null
|
||||
|
||||
private lateinit var binding: FragmentFeedBinding
|
||||
private var binding: FragmentFeedBinding by bindingLifecycleAware()
|
||||
|
||||
|
||||
private var job: Job? = null
|
||||
|
@ -49,6 +52,7 @@ open class CachedFeedFragment<T: FeedContentDatabase> : BaseFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
//TODO rename function to something that makes sense
|
||||
internal fun initSearch() {
|
||||
// Scroll to top when the list is refreshed from network.
|
||||
lifecycleScope.launchWhenStarted {
|
||||
|
@ -73,7 +77,9 @@ open class CachedFeedFragment<T: FeedContentDatabase> : BaseFragment() {
|
|||
binding = FragmentFeedBinding.inflate(layoutInflater)
|
||||
|
||||
initAdapter(binding.progressBar, binding.swipeRefreshLayout,
|
||||
binding.list, binding.motionLayout, binding.errorLayout, adapter)
|
||||
binding.list, binding.motionLayout, binding.errorLayout, adapter,
|
||||
headerAdapter
|
||||
)
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
|
|
@ -16,18 +16,20 @@
|
|||
|
||||
package org.pixeldroid.app.posts.feeds.cachedFeeds
|
||||
|
||||
import androidx.paging.*
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.RemoteMediator
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.pixeldroid.app.utils.api.objects.FeedContentDatabase
|
||||
import org.pixeldroid.app.utils.db.AppDatabase
|
||||
import org.pixeldroid.app.utils.db.dao.feedContent.FeedContentDao
|
||||
import org.pixeldroid.app.utils.api.objects.FeedContentDatabase
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Repository class that works with local and remote data sources.
|
||||
*/
|
||||
class FeedContentRepository<T: FeedContentDatabase> @ExperimentalPagingApi
|
||||
@Inject constructor(
|
||||
class FeedContentRepository<T: FeedContentDatabase> @ExperimentalPagingApi constructor(
|
||||
private val db: AppDatabase,
|
||||
private val dao: FeedContentDao<T>,
|
||||
private val mediator: RemoteMediator<Int, T>
|
||||
|
|
|
@ -221,8 +221,7 @@ class NotificationsFragment : CachedFeedFragment<Notification>() {
|
|||
setTextViewFromISO8601(
|
||||
it,
|
||||
notificationTime,
|
||||
false,
|
||||
itemView.context
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -21,8 +21,6 @@ import androidx.room.withTransaction
|
|||
import org.pixeldroid.app.utils.db.AppDatabase
|
||||
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
|
||||
import org.pixeldroid.app.utils.api.objects.Notification
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import java.lang.Exception
|
||||
import java.lang.NullPointerException
|
||||
import javax.inject.Inject
|
||||
|
@ -74,11 +72,7 @@ class NotificationsRemoteMediator @Inject constructor(
|
|||
db.notificationDao().insertAll(apiResponse)
|
||||
}
|
||||
return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
|
||||
} catch (exception: IOException) {
|
||||
return MediatorResult.Error(exception)
|
||||
} catch (exception: HttpException) {
|
||||
return MediatorResult.Error(exception)
|
||||
} catch (exception: Exception){
|
||||
} catch (exception: Exception){
|
||||
return MediatorResult.Error(exception)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
package org.pixeldroid.app.posts.feeds.cachedFeeds.postFeeds
|
||||
|
||||
import androidx.paging.*
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.LoadType
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import androidx.paging.RemoteMediator
|
||||
import androidx.room.withTransaction
|
||||
import org.pixeldroid.app.utils.db.AppDatabase
|
||||
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
|
||||
import org.pixeldroid.app.utils.db.entities.HomeStatusDatabaseEntity
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import java.lang.NullPointerException
|
||||
import javax.inject.Inject
|
||||
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
|
||||
|
||||
|
||||
/**
|
||||
|
@ -19,7 +19,7 @@ import javax.inject.Inject
|
|||
* a local db cache.
|
||||
*/
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
class HomeFeedRemoteMediator @Inject constructor(
|
||||
class HomeFeedRemoteMediator(
|
||||
private val apiHolder: PixelfedAPIHolder,
|
||||
private val db: AppDatabase,
|
||||
) : RemoteMediator<Int, HomeStatusDatabaseEntity>() {
|
||||
|
@ -49,7 +49,7 @@ class HomeFeedRemoteMediator @Inject constructor(
|
|||
HomeStatusDatabaseEntity(user.user_id, user.instance_uri, it)
|
||||
}
|
||||
|
||||
val endOfPaginationReached = apiResponse.isEmpty()
|
||||
val endOfPaginationReached = apiResponse.isEmpty() || maxId == apiResponse.sortedBy { it.created_at }.last().id
|
||||
|
||||
db.withTransaction {
|
||||
// Clear table in the database
|
||||
|
@ -59,9 +59,7 @@ class HomeFeedRemoteMediator @Inject constructor(
|
|||
db.homePostDao().insertAll(dbObjects)
|
||||
}
|
||||
return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
|
||||
} catch (exception: IOException) {
|
||||
return MediatorResult.Error(exception)
|
||||
} catch (exception: HttpException) {
|
||||
} catch (exception: Exception) {
|
||||
return MediatorResult.Error(exception)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,14 +11,14 @@ import androidx.paging.PagingDataAdapter
|
|||
import androidx.paging.RemoteMediator
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.utils.db.dao.feedContent.FeedContentDao
|
||||
import org.pixeldroid.app.posts.StatusViewHolder
|
||||
import org.pixeldroid.app.posts.feeds.cachedFeeds.FeedViewModel
|
||||
import org.pixeldroid.app.posts.feeds.cachedFeeds.CachedFeedFragment
|
||||
import org.pixeldroid.app.posts.feeds.cachedFeeds.FeedViewModel
|
||||
import org.pixeldroid.app.posts.feeds.cachedFeeds.ViewModelFactory
|
||||
import org.pixeldroid.app.stories.StoriesAdapter
|
||||
import org.pixeldroid.app.utils.api.objects.FeedContentDatabase
|
||||
import org.pixeldroid.app.utils.api.objects.Status
|
||||
import org.pixeldroid.app.utils.db.dao.feedContent.FeedContentDao
|
||||
import org.pixeldroid.app.utils.displayDimensionsInPx
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
|
@ -38,14 +38,18 @@ class PostFeedFragment<T: FeedContentDatabase>: CachedFeedFragment<T>() {
|
|||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
adapter = PostsAdapter(requireContext().displayDimensionsInPx())
|
||||
home = requireArguments().getBoolean("home")
|
||||
|
||||
home = requireArguments().get("home") as Boolean
|
||||
adapter = PostsAdapter(requireContext().displayDimensionsInPx())
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
if (home){
|
||||
mediator = HomeFeedRemoteMediator(apiHolder, db) as RemoteMediator<Int, T>
|
||||
dao = db.homePostDao() as FeedContentDao<T>
|
||||
headerAdapter = StoriesAdapter(lifecycleScope, apiHolder)
|
||||
headerAdapter?.showStories = false
|
||||
|
||||
headerAdapter?.refreshStories()
|
||||
}
|
||||
else {
|
||||
mediator = PublicFeedRemoteMediator(apiHolder, db) as RemoteMediator<Int, T>
|
||||
|
@ -55,7 +59,7 @@ class PostFeedFragment<T: FeedContentDatabase>: CachedFeedFragment<T>() {
|
|||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
savedInstanceState: Bundle?,
|
||||
): View? {
|
||||
|
||||
val view = super.onCreateView(inflater, container, savedInstanceState)
|
||||
|
@ -70,6 +74,7 @@ class PostFeedFragment<T: FeedContentDatabase>: CachedFeedFragment<T>() {
|
|||
return view
|
||||
}
|
||||
|
||||
|
||||
inner class PostsAdapter(private val displayDimensionsInPx: Pair<Int, Int>) : PagingDataAdapter<T, RecyclerView.ViewHolder>(
|
||||
object : DiffUtil.ItemCallback<T>() {
|
||||
override fun areItemsTheSame (oldItem: T, newItem: T): Boolean = oldItem.id == newItem.id
|
||||
|
@ -81,15 +86,19 @@ class PostFeedFragment<T: FeedContentDatabase>: CachedFeedFragment<T>() {
|
|||
return StatusViewHolder.create(parent)
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return R.layout.post_fragment
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
val uiModel = getItem(position) as Status?
|
||||
uiModel?.let {
|
||||
(holder as StatusViewHolder).bind(it, apiHolder, db, lifecycleScope, displayDimensionsInPx)
|
||||
}
|
||||
holder.itemView.visibility = View.VISIBLE
|
||||
holder.itemView.layoutParams =
|
||||
RecyclerView.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
val uiModel = getItem(position) as Status?
|
||||
uiModel?.let {
|
||||
(holder as StatusViewHolder).bind(
|
||||
it, apiHolder, db, lifecycleScope, displayDimensionsInPx, requestPermissionDownloadPic
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -16,15 +16,15 @@
|
|||
|
||||
package org.pixeldroid.app.posts.feeds.cachedFeeds.postFeeds
|
||||
|
||||
import androidx.paging.*
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.LoadType
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import androidx.paging.RemoteMediator
|
||||
import androidx.room.withTransaction
|
||||
import org.pixeldroid.app.utils.db.AppDatabase
|
||||
import org.pixeldroid.app.utils.db.entities.PublicFeedStatusDatabaseEntity
|
||||
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import java.lang.NullPointerException
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* RemoteMediator for the public feed.
|
||||
|
@ -34,7 +34,7 @@ import javax.inject.Inject
|
|||
* a local db cache.
|
||||
*/
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
class PublicFeedRemoteMediator @Inject constructor(
|
||||
class PublicFeedRemoteMediator(
|
||||
private val apiHolder: PixelfedAPIHolder,
|
||||
private val db: AppDatabase
|
||||
) : RemoteMediator<Int, PublicFeedStatusDatabaseEntity>() {
|
||||
|
@ -64,7 +64,7 @@ class PublicFeedRemoteMediator @Inject constructor(
|
|||
val dbObjects = apiResponse.map{
|
||||
PublicFeedStatusDatabaseEntity(user.user_id, user.instance_uri, it)
|
||||
}
|
||||
val endOfPaginationReached = apiResponse.isEmpty()
|
||||
val endOfPaginationReached = apiResponse.isEmpty() || maxId == apiResponse.sortedBy { it.created_at }.last().id
|
||||
|
||||
db.withTransaction {
|
||||
// Clear table in the database
|
||||
|
@ -74,9 +74,7 @@ class PublicFeedRemoteMediator @Inject constructor(
|
|||
db.publicPostDao().insertAll(dbObjects)
|
||||
}
|
||||
return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
|
||||
} catch (exception: IOException) {
|
||||
return MediatorResult.Error(exception)
|
||||
} catch (exception: HttpException) {
|
||||
} catch (exception: Exception) {
|
||||
return MediatorResult.Error(exception)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import androidx.paging.ExperimentalPagingApi
|
|||
import androidx.paging.LoadState
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.filter
|
||||
|
@ -20,6 +21,7 @@ import org.pixeldroid.app.posts.feeds.initAdapter
|
|||
import org.pixeldroid.app.posts.feeds.launch
|
||||
import org.pixeldroid.app.utils.BaseFragment
|
||||
import org.pixeldroid.app.utils.api.objects.FeedContent
|
||||
import org.pixeldroid.app.utils.limitedLengthSmoothScrollToPosition
|
||||
|
||||
|
||||
/**
|
||||
|
@ -30,8 +32,7 @@ open class UncachedFeedFragment<T: FeedContent> : BaseFragment() {
|
|||
internal lateinit var viewModel: FeedViewModel<T>
|
||||
internal lateinit var adapter: PagingDataAdapter<T, RecyclerView.ViewHolder>
|
||||
|
||||
lateinit var binding: FragmentFeedBinding
|
||||
|
||||
var binding: FragmentFeedBinding? = null
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
|
@ -48,23 +49,35 @@ open class UncachedFeedFragment<T: FeedContent> : BaseFragment() {
|
|||
.distinctUntilChangedBy { it.refresh }
|
||||
// Only react to cases where Remote REFRESH completes i.e., NotLoading.
|
||||
.filter { it.refresh is LoadState.NotLoading }
|
||||
.collect { binding.list.scrollToPosition(0) }
|
||||
.collect { binding?.list?.scrollToPosition(0) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
|
||||
savedInstanceState: Bundle?, swipeRefreshLayout: SwipeRefreshLayout?
|
||||
): View {
|
||||
super.onCreateView(inflater, container, savedInstanceState)
|
||||
|
||||
binding = FragmentFeedBinding.inflate(layoutInflater)
|
||||
|
||||
initAdapter(binding.progressBar, binding.swipeRefreshLayout, binding.list,
|
||||
binding.motionLayout, binding.errorLayout, adapter)
|
||||
binding!!.let {
|
||||
initAdapter(
|
||||
it.progressBar, swipeRefreshLayout ?: it.swipeRefreshLayout, it.list,
|
||||
it.motionLayout, it.errorLayout, adapter
|
||||
)
|
||||
|
||||
return binding.root
|
||||
}
|
||||
return binding!!.root
|
||||
}
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return onCreateView(inflater, container, savedInstanceState, null)
|
||||
}
|
||||
fun onTabReClicked() {
|
||||
binding?.list?.limitedLengthSmoothScrollToPosition(0)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -85,7 +85,9 @@ class UncachedPostsFragment : UncachedFeedFragment<Status>() {
|
|||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
getItem(position)?.let {
|
||||
(holder as StatusViewHolder).bind(it, apiHolder, db, lifecycleScope, displayDimensionsInPx)
|
||||
(holder as StatusViewHolder).bind(
|
||||
it, apiHolder, db, lifecycleScope, displayDimensionsInPx, requestPermissionDownloadPic
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,6 @@ import androidx.paging.PagingState
|
|||
import org.pixeldroid.app.utils.api.PixelfedAPI
|
||||
import org.pixeldroid.app.utils.api.objects.Account
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
|
||||
class FollowersPagingSource(
|
||||
private val api: PixelfedAPI,
|
||||
|
@ -58,9 +57,7 @@ class FollowersPagingSource(
|
|||
prevKey = null,
|
||||
nextKey = if (accounts.isEmpty() || nextPosition.isEmpty() || nextPosition == position) null else nextPosition
|
||||
)
|
||||
} catch (exception: IOException) {
|
||||
LoadResult.Error(exception)
|
||||
} catch (exception: HttpException) {
|
||||
} catch (exception: Exception) {
|
||||
LoadResult.Error(exception)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,12 +5,15 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.NestedScrollingChild
|
||||
import androidx.core.view.NestedScrollingChildHelper
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.CommentBinding
|
||||
import org.pixeldroid.app.posts.PostActivity
|
||||
|
@ -25,7 +28,7 @@ import org.pixeldroid.app.utils.setProfileImageFromURL
|
|||
/**
|
||||
* Fragment to show a list of [Status]s, in form of comments
|
||||
*/
|
||||
class CommentFragment : UncachedFeedFragment<Status>() {
|
||||
class CommentFragment(val swipeRefreshLayout: SwipeRefreshLayout): UncachedFeedFragment<Status>() {
|
||||
|
||||
private lateinit var id: String
|
||||
private lateinit var domain: String
|
||||
|
@ -42,11 +45,11 @@ class CommentFragment : UncachedFeedFragment<Status>() {
|
|||
@OptIn(ExperimentalPagingApi::class)
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
savedInstanceState: Bundle?,
|
||||
): View? {
|
||||
|
||||
|
||||
val view = super.onCreateView(inflater, container, savedInstanceState)
|
||||
val view = super.onCreateView(inflater, container, savedInstanceState, swipeRefreshLayout)
|
||||
|
||||
// Get the view model
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
|
@ -62,6 +65,7 @@ class CommentFragment : UncachedFeedFragment<Status>() {
|
|||
launch()
|
||||
initSearch()
|
||||
|
||||
binding?.swipeRefreshLayout?.isEnabled = false
|
||||
return view
|
||||
}
|
||||
companion object {
|
||||
|
|
|
@ -4,8 +4,6 @@ import androidx.paging.PagingSource
|
|||
import androidx.paging.PagingState
|
||||
import org.pixeldroid.app.utils.api.PixelfedAPI
|
||||
import org.pixeldroid.app.utils.api.objects.Status
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
|
||||
class CommentPagingSource(
|
||||
private val api: PixelfedAPI,
|
||||
|
@ -23,9 +21,7 @@ class CommentPagingSource(
|
|||
prevKey = null,
|
||||
nextKey = null
|
||||
)
|
||||
} catch (exception: HttpException) {
|
||||
LoadResult.Error(exception)
|
||||
} catch (exception: IOException) {
|
||||
} catch (exception: Exception) {
|
||||
LoadResult.Error(exception)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,17 +2,23 @@ package org.pixeldroid.app.posts.feeds.uncachedFeeds.hashtags
|
|||
|
||||
import android.os.Bundle
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.ActivityFollowersBinding
|
||||
import org.pixeldroid.app.posts.feeds.uncachedFeeds.UncachedPostsFragment
|
||||
import org.pixeldroid.app.utils.BaseThemedWithBarActivity
|
||||
import org.pixeldroid.app.utils.BaseActivity
|
||||
import org.pixeldroid.app.utils.api.objects.Tag.Companion.HASHTAG_TAG
|
||||
|
||||
|
||||
class HashTagActivity : BaseThemedWithBarActivity() {
|
||||
class HashTagActivity : BaseActivity() {
|
||||
private var tagFragment = UncachedPostsFragment()
|
||||
private lateinit var binding: ActivityFollowersBinding
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_followers)
|
||||
binding = ActivityFollowersBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.topBar)
|
||||
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
// Get hashtag tag
|
||||
|
|
|
@ -30,9 +30,7 @@ class HashTagPagingSource(
|
|||
prevKey = null,
|
||||
nextKey = if(nextKey == position) null else nextKey
|
||||
)
|
||||
} catch (exception: HttpException) {
|
||||
LoadResult.Error(exception)
|
||||
} catch (exception: IOException) {
|
||||
} catch (exception: Exception) {
|
||||
LoadResult.Error(exception)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
package org.pixeldroid.app.posts.feeds.uncachedFeeds.profile
|
||||
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import org.pixeldroid.app.posts.feeds.uncachedFeeds.UncachedContentRepository
|
||||
import org.pixeldroid.app.utils.api.PixelfedAPI
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.pixeldroid.app.utils.api.objects.Collection
|
||||
import javax.inject.Inject
|
||||
|
||||
class CollectionsContentRepository @ExperimentalPagingApi
|
||||
@Inject constructor(
|
||||
private val api: PixelfedAPI,
|
||||
private val accountId: String,
|
||||
) : UncachedContentRepository<Collection> {
|
||||
override fun getStream(): Flow<PagingData<Collection>> {
|
||||
return Pager(
|
||||
config = PagingConfig(
|
||||
initialLoadSize = NETWORK_PAGE_SIZE,
|
||||
pageSize = NETWORK_PAGE_SIZE),
|
||||
pagingSourceFactory = {
|
||||
CollectionsPagingSource(api, accountId)
|
||||
}
|
||||
).flow
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val NETWORK_PAGE_SIZE = 20
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package org.pixeldroid.app.posts.feeds.uncachedFeeds.profile
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import org.pixeldroid.app.utils.api.PixelfedAPI
|
||||
import org.pixeldroid.app.utils.api.objects.Collection
|
||||
|
||||
class CollectionsPagingSource(
|
||||
private val api: PixelfedAPI,
|
||||
private val accountId: String,
|
||||
) : PagingSource<String, Collection>() {
|
||||
override suspend fun load(params: LoadParams<String>): LoadResult<String, Collection> {
|
||||
return try {
|
||||
val posts = api.accountCollections(accountId)
|
||||
|
||||
LoadResult.Page(
|
||||
data = posts,
|
||||
prevKey = null,
|
||||
//TODO pagination. For now, don't paginate
|
||||
nextKey = null
|
||||
)
|
||||
} catch (exception: Exception) {
|
||||
LoadResult.Error(exception)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getRefreshKey(state: PagingState<String, Collection>): String? = null
|
||||
}
|
|
@ -14,7 +14,8 @@ class ProfileContentRepository @ExperimentalPagingApi
|
|||
@Inject constructor(
|
||||
private val api: PixelfedAPI,
|
||||
private val accountId: String,
|
||||
private val bookmarks: Boolean
|
||||
private val bookmarks: Boolean,
|
||||
private val collectionId: String?,
|
||||
) : UncachedContentRepository<Status> {
|
||||
override fun getStream(): Flow<PagingData<Status>> {
|
||||
return Pager(
|
||||
|
@ -22,8 +23,9 @@ class ProfileContentRepository @ExperimentalPagingApi
|
|||
initialLoadSize = NETWORK_PAGE_SIZE,
|
||||
pageSize = NETWORK_PAGE_SIZE),
|
||||
pagingSourceFactory = {
|
||||
ProfilePagingSource(api, accountId, bookmarks)
|
||||
}
|
||||
ProfilePagingSource(api, accountId, bookmarks, collectionId)
|
||||
},
|
||||
initialKey = if(collectionId != null) "1" else null
|
||||
).flow
|
||||
}
|
||||
|
||||
|
|
|
@ -4,19 +4,24 @@ import androidx.paging.PagingSource
|
|||
import androidx.paging.PagingState
|
||||
import org.pixeldroid.app.utils.api.PixelfedAPI
|
||||
import org.pixeldroid.app.utils.api.objects.Status
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
|
||||
class ProfilePagingSource(
|
||||
private val api: PixelfedAPI,
|
||||
private val accountId: String,
|
||||
private val bookmarks: Boolean
|
||||
private val bookmarks: Boolean,
|
||||
private val collectionId: String?,
|
||||
) : PagingSource<String, Status>() {
|
||||
override suspend fun load(params: LoadParams<String>): LoadResult<String, Status> {
|
||||
val position = params.key
|
||||
return try {
|
||||
val posts =
|
||||
if(bookmarks) {
|
||||
if(collectionId != null){
|
||||
api.collectionItems(
|
||||
collectionId,
|
||||
page = position
|
||||
)
|
||||
}
|
||||
else if(bookmarks) {
|
||||
api.bookmarks(
|
||||
limit = params.loadSize,
|
||||
max_id = position
|
||||
|
@ -34,11 +39,11 @@ class ProfilePagingSource(
|
|||
LoadResult.Page(
|
||||
data = posts,
|
||||
prevKey = null,
|
||||
nextKey = if(nextKey == position) null else nextKey
|
||||
nextKey = if(collectionId != null ) {
|
||||
if(posts.isEmpty()) null else (params.key?.toIntOrNull()?.plus(1))?.toString()
|
||||
} else if(nextKey == position) null else nextKey
|
||||
)
|
||||
} catch (exception: HttpException) {
|
||||
LoadResult.Error(exception)
|
||||
} catch (exception: IOException) {
|
||||
} catch (exception: Exception) {
|
||||
LoadResult.Error(exception)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,8 +5,6 @@ import androidx.paging.PagingState
|
|||
import org.pixeldroid.app.utils.api.PixelfedAPI
|
||||
import org.pixeldroid.app.utils.api.objects.FeedContent
|
||||
import org.pixeldroid.app.utils.api.objects.Results
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Provides the PagingSource for search feeds. Is used in [SearchContentRepository]
|
||||
|
@ -41,9 +39,7 @@ class SearchPagingSource<T: FeedContent>(
|
|||
prevKey = null,
|
||||
nextKey = if(nextKey == position) null else nextKey
|
||||
)
|
||||
} catch (exception: HttpException) {
|
||||
LoadResult.Error(exception)
|
||||
} catch (exception: IOException) {
|
||||
} catch (exception: Exception) {
|
||||
LoadResult.Error(exception)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,141 @@
|
|||
package org.pixeldroid.app.profile
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.launch
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.ActivityCollectionBinding
|
||||
import org.pixeldroid.app.profile.ProfileFeedFragment.Companion.COLLECTION
|
||||
import org.pixeldroid.app.profile.ProfileFeedFragment.Companion.COLLECTION_ID
|
||||
import org.pixeldroid.app.utils.BaseActivity
|
||||
import org.pixeldroid.app.utils.api.PixelfedAPI
|
||||
import org.pixeldroid.app.utils.api.objects.Collection
|
||||
import java.lang.Exception
|
||||
|
||||
class CollectionActivity : BaseActivity() {
|
||||
private lateinit var binding: ActivityCollectionBinding
|
||||
|
||||
private lateinit var collection: Collection
|
||||
private var addCollection: Boolean = false
|
||||
private var deleteFromCollection: Boolean = false
|
||||
|
||||
companion object {
|
||||
const val COLLECTION_TAG = "Collection"
|
||||
const val ADD_COLLECTION_TAG = "AddCollection"
|
||||
const val DELETE_FROM_COLLECTION_TAG = "DeleteFromCollection"
|
||||
const val DELETE_FROM_COLLECTION_RESULT = "DeleteFromCollectionResult"
|
||||
const val ADD_TO_COLLECTION_RESULT = "AddToCollectionResult"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityCollectionBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.topBar)
|
||||
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
collection = intent.getSerializableExtra(COLLECTION_TAG) as Collection
|
||||
|
||||
addCollection = intent.getBooleanExtra(ADD_COLLECTION_TAG, false)
|
||||
deleteFromCollection = intent.getBooleanExtra(DELETE_FROM_COLLECTION_TAG, false)
|
||||
|
||||
val addedResult = intent.getBooleanExtra(ADD_TO_COLLECTION_RESULT, false)
|
||||
val deletedResult = intent.getBooleanExtra(DELETE_FROM_COLLECTION_RESULT, false)
|
||||
|
||||
if(addedResult)
|
||||
Snackbar.make(
|
||||
binding.root, getString(R.string.added_post_to_collection),
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
else if (deletedResult) Snackbar.make(
|
||||
binding.root, getString(R.string.removed_post_from_collection),
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
|
||||
supportActionBar?.title = if(addCollection) getString(R.string.add_to_collection)
|
||||
else if(deleteFromCollection) getString(R.string.delete_from_collection)
|
||||
else getString(R.string.collection_title).format(collection.username)
|
||||
|
||||
val collectionFragment = ProfileFeedFragment()
|
||||
collectionFragment.arguments = Bundle().apply {
|
||||
putBoolean(COLLECTION, true)
|
||||
putString(COLLECTION_ID, collection.id)
|
||||
putSerializable(COLLECTION, collection)
|
||||
if(addCollection) putBoolean(ADD_COLLECTION_TAG, true)
|
||||
else if (deleteFromCollection) putBoolean(DELETE_FROM_COLLECTION_TAG, true)
|
||||
}
|
||||
|
||||
supportFragmentManager.beginTransaction()
|
||||
.add(R.id.collectionFragment, collectionFragment).commit()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
val userId = db.userDao().getActiveUser()?.user_id
|
||||
|
||||
// Only show options for editing a collection if it's the user's collection
|
||||
if(!(addCollection || deleteFromCollection) && userId != null && collection.pid == userId) {
|
||||
val inflater: MenuInflater = menuInflater
|
||||
inflater.inflate(R.menu.collection_menu, menu)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
// Relaunch same activity, to avoid duplicates in history
|
||||
super.onNewIntent(intent)
|
||||
finish()
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.delete_collection -> {
|
||||
MaterialAlertDialogBuilder(this).apply {
|
||||
setMessage(R.string.delete_collection_warning)
|
||||
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
// Delete collection
|
||||
lifecycleScope.launch {
|
||||
val api: PixelfedAPI = apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
try {
|
||||
api.deleteCollection(collection.id)
|
||||
// Deleted, exit activity
|
||||
finish()
|
||||
} catch (exception: Exception) {
|
||||
Snackbar.make(
|
||||
binding.root, getString(R.string.something_went_wrong),
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
}.show()
|
||||
true
|
||||
}
|
||||
R.id.add_post_collection -> {
|
||||
val intent = Intent(this, CollectionActivity::class.java)
|
||||
intent.putExtra(COLLECTION_TAG, collection)
|
||||
intent.putExtra(ADD_COLLECTION_TAG, true)
|
||||
startActivity(intent)
|
||||
true
|
||||
}
|
||||
R.id.remove_post_collection -> {
|
||||
val intent = Intent(this, CollectionActivity::class.java)
|
||||
intent.putExtra(COLLECTION_TAG, collection)
|
||||
intent.putExtra(DELETE_FROM_COLLECTION_TAG, true)
|
||||
startActivity(intent)
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,161 @@
|
|||
package org.pixeldroid.app.profile
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.launch
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.ActivityEditProfileBinding
|
||||
import org.pixeldroid.app.utils.BaseActivity
|
||||
import org.pixeldroid.app.utils.openUrl
|
||||
|
||||
class EditProfileActivity : BaseActivity() {
|
||||
|
||||
private val model: EditProfileViewModel by viewModels()
|
||||
private lateinit var binding: ActivityEditProfileBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityEditProfileBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.topBar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
onBackPressedDispatcher.addCallback(this) {
|
||||
// Handle the back button event
|
||||
if(model.madeChanges()){
|
||||
MaterialAlertDialogBuilder(binding.root.context).apply {
|
||||
setMessage(getString(R.string.profile_save_changes))
|
||||
setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
this@addCallback.isEnabled = false
|
||||
super.onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
}.show()
|
||||
} else {
|
||||
this.isEnabled = false
|
||||
if (model.submittedChanges) setResult(RESULT_OK)
|
||||
super.onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
model.uiState.collect { uiState ->
|
||||
if(binding.bioEditText.text.toString() != uiState.bio) binding.bioEditText.setText(uiState.bio)
|
||||
if(binding.nameEditText.text.toString() != uiState.name) binding.nameEditText.setText(uiState.name)
|
||||
|
||||
binding.progressCard.visibility = if(uiState.loadingProfile || uiState.sendingProfile || uiState.uploadingPicture || uiState.profileSent || uiState.error) View.VISIBLE else View.INVISIBLE
|
||||
|
||||
if(uiState.loadingProfile) binding.progressText.setText(R.string.fetching_profile)
|
||||
else if(uiState.sendingProfile) binding.progressText.setText(R.string.saving_profile)
|
||||
|
||||
binding.privateSwitch.isChecked = uiState.privateAccount == true
|
||||
Glide.with(binding.profilePic).load(uiState.profilePictureUri)
|
||||
.apply(RequestOptions.circleCropTransform())
|
||||
.into(binding.profilePic)
|
||||
|
||||
binding.savingProgressBar.visibility =
|
||||
if(uiState.error || (uiState.profileSent && !uiState.uploadingPicture)) View.GONE
|
||||
else View.VISIBLE
|
||||
|
||||
if(uiState.profileSent && !uiState.uploadingPicture && !uiState.error){
|
||||
binding.progressText.setText(R.string.profile_saved)
|
||||
binding.done.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.done.visibility = View.GONE
|
||||
}
|
||||
if(uiState.error){
|
||||
binding.progressText.setText(R.string.error_profile)
|
||||
binding.error.visibility = View.VISIBLE
|
||||
} else binding.error.visibility = View.GONE
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.bioEditText.doAfterTextChanged {
|
||||
model.updateBio(binding.bioEditText.text)
|
||||
}
|
||||
binding.nameEditText.doAfterTextChanged {
|
||||
model.updateName(binding.nameEditText.text)
|
||||
}
|
||||
binding.privateSwitch.setOnCheckedChangeListener { _, isChecked ->
|
||||
model.updatePrivate(isChecked)
|
||||
}
|
||||
|
||||
binding.progressCard.setOnClickListener {
|
||||
model.clickedCard()
|
||||
}
|
||||
|
||||
binding.editButton.setOnClickListener {
|
||||
val domain = db.userDao().getActiveUser()!!.instance_uri
|
||||
val url = "$domain/settings/home"
|
||||
|
||||
if(!openUrl(url)) {
|
||||
Snackbar.make(binding.root, getString(R.string.edit_link_failed),
|
||||
Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
binding.profilePic.setOnClickListener {
|
||||
Intent(Intent.ACTION_GET_CONTENT).apply {
|
||||
type = "*/*"
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*"))
|
||||
action = Intent.ACTION_GET_CONTENT
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)
|
||||
uploadImageResultContract.launch(
|
||||
Intent.createChooser(this, null)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val uploadImageResultContract = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val data: Intent? = result.data
|
||||
if (result.resultCode == Activity.RESULT_OK && data != null) {
|
||||
val images: ArrayList<String> = ArrayList()
|
||||
val clipData = data.clipData
|
||||
if (clipData != null) {
|
||||
val count = clipData.itemCount
|
||||
for (i in 0 until count) {
|
||||
val imageUri: String = clipData.getItemAt(i).uri.toString()
|
||||
images.add(imageUri)
|
||||
}
|
||||
model.updateImage(images.first())
|
||||
} else if (data.data != null) {
|
||||
images.add(data.data!!.toString())
|
||||
model.updateImage(images.first())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.edit_profile_menu, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId){
|
||||
R.id.action_apply -> {
|
||||
model.sendProfile()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,307 @@
|
|||
package org.pixeldroid.app.profile
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.text.Editable
|
||||
import android.util.Log
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.MultipartBody
|
||||
import org.pixeldroid.app.postCreation.ProgressRequestBody
|
||||
import org.pixeldroid.app.posts.fromHtml
|
||||
import org.pixeldroid.app.utils.api.objects.Account
|
||||
import org.pixeldroid.app.utils.db.AppDatabase
|
||||
import org.pixeldroid.app.utils.db.updateUserInfoDb
|
||||
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
|
||||
import retrofit2.HttpException
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class EditProfileViewModel @Inject constructor(
|
||||
@ApplicationContext private val applicationContext: Context
|
||||
): ViewModel() {
|
||||
|
||||
@Inject
|
||||
lateinit var apiHolder: PixelfedAPIHolder
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
private val _uiState = MutableStateFlow(EditProfileActivityUiState())
|
||||
val uiState: StateFlow<EditProfileActivityUiState> = _uiState
|
||||
|
||||
private var oldProfile: Account? = null
|
||||
|
||||
var submittedChanges = false
|
||||
private set
|
||||
|
||||
init {
|
||||
loadProfile()
|
||||
}
|
||||
|
||||
private fun loadProfile() {
|
||||
viewModelScope.launch {
|
||||
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
try {
|
||||
val profile = api.verifyCredentials()
|
||||
updateUserInfoDb(db, profile)
|
||||
if (oldProfile == null) oldProfile = profile
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
name = oldProfile?.display_name,
|
||||
bio = oldProfile?.source?.note,
|
||||
profilePictureUri = oldProfile?.anyAvatar()?.toUri(),
|
||||
privateAccount = oldProfile?.locked,
|
||||
loadingProfile = false,
|
||||
sendingProfile = false,
|
||||
profileLoaded = true,
|
||||
error = false
|
||||
)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
sendingProfile = false,
|
||||
profileSent = false,
|
||||
loadingProfile = false,
|
||||
profileLoaded = false,
|
||||
error = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendProfile() {
|
||||
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
sendingProfile = true,
|
||||
profileSent = false,
|
||||
error = false
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
with(uiState.value) {
|
||||
try {
|
||||
val account = api.updateCredentials(
|
||||
displayName = name,
|
||||
note = bio,
|
||||
locked = privateAccount,
|
||||
)
|
||||
if (madeChanges()) submittedChanges = true
|
||||
oldProfile = account
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
bio = account.source?.note
|
||||
?: account.note?.let { fromHtml(it).toString() },
|
||||
name = account.display_name,
|
||||
profilePictureUri = if (profilePictureChanged) profilePictureUri
|
||||
else account.anyAvatar()?.toUri(),
|
||||
uploadProgress = 0,
|
||||
uploadingPicture = profilePictureChanged,
|
||||
privateAccount = account.locked,
|
||||
sendingProfile = false,
|
||||
profileSent = true,
|
||||
loadingProfile = false,
|
||||
profileLoaded = true,
|
||||
error = false
|
||||
)
|
||||
}
|
||||
if(profilePictureChanged) uploadImage()
|
||||
} catch (exception: Exception) {
|
||||
Log.e("TAG", exception.toString())
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
sendingProfile = false,
|
||||
profileSent = false,
|
||||
error = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBio(bio: Editable?) {
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(bio = bio.toString())
|
||||
}
|
||||
}
|
||||
|
||||
fun updateName(name: Editable?) {
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(name = name.toString())
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePrivate(isChecked: Boolean) {
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(privateAccount = isChecked)
|
||||
}
|
||||
}
|
||||
|
||||
fun madeChanges(): Boolean =
|
||||
with(uiState.value) {
|
||||
val privateChanged = oldProfile?.locked != privateAccount
|
||||
val displayNameChanged = oldProfile?.display_name != name
|
||||
val bioChanged: Boolean = oldProfile?.source?.note?.let { it != bio }
|
||||
// If source note is null, check note
|
||||
?: oldProfile?.note?.let { fromHtml(it).toString() != bio }
|
||||
?: true
|
||||
|
||||
profilePictureChanged || privateChanged || displayNameChanged || bioChanged
|
||||
}
|
||||
|
||||
fun clickedCard() {
|
||||
if (uiState.value.error) {
|
||||
if (!uiState.value.profileLoaded) {
|
||||
// Load failed
|
||||
loadProfile()
|
||||
} else if (uiState.value.profileLoaded) {
|
||||
// Send failed
|
||||
sendProfile()
|
||||
}
|
||||
} else {
|
||||
// Dismiss success card
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(profileSent = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateImage(image: String) {
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
profilePictureUri = image.toUri(),
|
||||
profilePictureChanged = true,
|
||||
profileSent = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun uploadImage() {
|
||||
val image = uiState.value.profilePictureUri!!
|
||||
|
||||
val inputStream =
|
||||
applicationContext.contentResolver.openInputStream(image)
|
||||
?: return
|
||||
|
||||
val size: Long =
|
||||
if (image.scheme == "content") {
|
||||
applicationContext.contentResolver.query(
|
||||
image,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
?.use { cursor ->
|
||||
/* Get the column indexes of the data in the Cursor,
|
||||
* move to the first row in the Cursor, get the data,
|
||||
* and display it.
|
||||
*/
|
||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
cursor.moveToFirst()
|
||||
cursor.getLong(sizeIndex)
|
||||
} ?: 0
|
||||
} else {
|
||||
image.toFile().length()
|
||||
}
|
||||
|
||||
val imagePart = ProgressRequestBody(inputStream, size, "image/*")
|
||||
|
||||
val requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("avatar", System.currentTimeMillis().toString(), imagePart)
|
||||
.build()
|
||||
val sub = imagePart.progressSubject
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe { percentage ->
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
uploadProgress = percentage.toInt()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var postSub: Disposable? = null
|
||||
|
||||
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
|
||||
val pixelfed = db.instanceDao().getActiveInstance().pixelfed
|
||||
|
||||
val inter =
|
||||
if(pixelfed) api.updateProfilePicture(requestBody.parts[0])
|
||||
else api.updateProfilePictureMastodon(requestBody.parts[0])
|
||||
|
||||
postSub = inter
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
/* onNext = */ { account: Account ->
|
||||
account.anyAvatar()?.let {
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
profilePictureUri = it.toUri()
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
/* onError = */ { e: Throwable ->
|
||||
Log.e("error", (e as? HttpException)?.message().orEmpty())
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
uploadProgress = 0,
|
||||
uploadingPicture = false,
|
||||
error = true
|
||||
)
|
||||
}
|
||||
e.printStackTrace()
|
||||
postSub?.dispose()
|
||||
sub.dispose()
|
||||
},
|
||||
/* onComplete = */ {
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
profilePictureChanged = false,
|
||||
uploadProgress = 100,
|
||||
uploadingPicture = false
|
||||
)
|
||||
}
|
||||
postSub?.dispose()
|
||||
sub.dispose()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
data class EditProfileActivityUiState(
|
||||
val name: String? = null,
|
||||
val bio: String? = null,
|
||||
val profilePictureUri: Uri? = null,
|
||||
val profilePictureChanged: Boolean = false,
|
||||
val privateAccount: Boolean? = null,
|
||||
val loadingProfile: Boolean = true,
|
||||
val profileLoaded: Boolean = false,
|
||||
val sendingProfile: Boolean = false,
|
||||
val profileSent: Boolean = false,
|
||||
val error: Boolean = false,
|
||||
val uploadingPicture: Boolean = false,
|
||||
val uploadProgress: Int = 0,
|
||||
)
|
|
@ -2,20 +2,25 @@ package org.pixeldroid.app.profile
|
|||
|
||||
import android.os.Bundle
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.ActivityFollowersBinding
|
||||
import org.pixeldroid.app.posts.feeds.uncachedFeeds.accountLists.AccountListFragment
|
||||
import org.pixeldroid.app.utils.BaseThemedWithBarActivity
|
||||
import org.pixeldroid.app.utils.BaseActivity
|
||||
import org.pixeldroid.app.utils.api.objects.Account
|
||||
import org.pixeldroid.app.utils.api.objects.Account.Companion.ACCOUNT_ID_TAG
|
||||
import org.pixeldroid.app.utils.api.objects.Account.Companion.ACCOUNT_TAG
|
||||
import org.pixeldroid.app.utils.api.objects.Account.Companion.FOLLOWERS_TAG
|
||||
|
||||
|
||||
class FollowsActivity : BaseThemedWithBarActivity() {
|
||||
class FollowsActivity : BaseActivity() {
|
||||
private var followsFragment = AccountListFragment()
|
||||
private lateinit var binding: ActivityFollowersBinding
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_followers)
|
||||
binding = ActivityFollowersBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.topBar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
|
||||
|
|
|
@ -2,39 +2,37 @@ package org.pixeldroid.app.profile
|
|||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.constraintlayout.motion.widget.MotionLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import kotlinx.coroutines.launch
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.ActivityProfileBinding
|
||||
import org.pixeldroid.app.databinding.FragmentProfilePostsBinding
|
||||
import org.pixeldroid.app.posts.PostActivity
|
||||
import org.pixeldroid.app.posts.feeds.cachedFeeds.CachedFeedFragment
|
||||
import org.pixeldroid.app.posts.feeds.uncachedFeeds.UncachedFeedFragment
|
||||
import org.pixeldroid.app.posts.parseHTMLText
|
||||
import org.pixeldroid.app.utils.*
|
||||
import org.pixeldroid.app.utils.BaseActivity
|
||||
import org.pixeldroid.app.utils.api.PixelfedAPI
|
||||
import org.pixeldroid.app.utils.api.objects.Account
|
||||
import org.pixeldroid.app.utils.api.objects.Attachment
|
||||
import org.pixeldroid.app.utils.api.objects.Status
|
||||
import org.pixeldroid.app.utils.api.objects.FeedContent
|
||||
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
|
||||
import org.pixeldroid.app.utils.db.updateUserInfoDb
|
||||
import org.pixeldroid.app.utils.setProfileImageFromURL
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
|
||||
class ProfileActivity : BaseThemedWithBarActivity() {
|
||||
class ProfileActivity : BaseActivity() {
|
||||
|
||||
private lateinit var domain : String
|
||||
private lateinit var accountId : String
|
||||
|
@ -45,7 +43,10 @@ class ProfileActivity : BaseThemedWithBarActivity() {
|
|||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityProfileBinding.inflate(layoutInflater)
|
||||
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.topBar)
|
||||
|
||||
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
|
@ -60,49 +61,76 @@ class ProfileActivity : BaseThemedWithBarActivity() {
|
|||
val tabs = createProfileTabs(account)
|
||||
setupTabs(tabs)
|
||||
setContent(account)
|
||||
|
||||
binding.profileMotion.setTransitionListener(
|
||||
object : MotionLayout.TransitionListener {
|
||||
override fun onTransitionStarted(
|
||||
motionLayout: MotionLayout?, startId: Int, endId: Int,
|
||||
) {}
|
||||
|
||||
override fun onTransitionChange(p0: MotionLayout?, p1: Int, p2: Int, p3: Float) {}
|
||||
|
||||
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {
|
||||
if (currentId == R.id.hideProfile && motionLayout?.startState == R.id.start) {
|
||||
// If the 1st transition has been made go to the second one
|
||||
motionLayout.setTransition(R.id.second)
|
||||
} else if(currentId == R.id.hideProfile && motionLayout?.startState == R.id.hideProfile){
|
||||
motionLayout.setTransition(R.id.first)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTransitionTrigger(
|
||||
motionLayout: MotionLayout?, triggerId: Int, positive: Boolean, progress: Float,
|
||||
) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun createProfileTabs(account: Account?): Array<Fragment>{
|
||||
private fun createProfileTabs(account: Account?): Array<UncachedFeedFragment<FeedContent>> {
|
||||
|
||||
val profileFeedFragment = ProfileFeedFragment()
|
||||
val argumentsFeed = Bundle().apply {
|
||||
profileFeedFragment.arguments = Bundle().apply {
|
||||
putSerializable(Account.ACCOUNT_TAG, account)
|
||||
putSerializable(ProfileFeedFragment.PROFILE_GRID, false)
|
||||
putSerializable(ProfileFeedFragment.BOOKMARKS, false)
|
||||
}
|
||||
profileFeedFragment.arguments = argumentsFeed
|
||||
|
||||
val profileGridFragment = ProfileFeedFragment()
|
||||
val argumentsGrid = Bundle().apply {
|
||||
profileGridFragment.arguments = Bundle().apply {
|
||||
putSerializable(Account.ACCOUNT_TAG, account)
|
||||
putSerializable(ProfileFeedFragment.PROFILE_GRID, true)
|
||||
putSerializable(ProfileFeedFragment.BOOKMARKS, false)
|
||||
}
|
||||
profileGridFragment.arguments = argumentsGrid
|
||||
|
||||
val profileCollectionsFragment = ProfileFeedFragment()
|
||||
profileCollectionsFragment.arguments = Bundle().apply {
|
||||
putSerializable(Account.ACCOUNT_TAG, account)
|
||||
putSerializable(ProfileFeedFragment.PROFILE_GRID, true)
|
||||
putSerializable(ProfileFeedFragment.BOOKMARKS, false)
|
||||
putSerializable(ProfileFeedFragment.COLLECTIONS, true)
|
||||
}
|
||||
|
||||
val returnArray: Array<UncachedFeedFragment<FeedContent>> = arrayOf(
|
||||
profileGridFragment,
|
||||
profileFeedFragment,
|
||||
profileCollectionsFragment
|
||||
)
|
||||
|
||||
// If we are viewing our own account, show bookmarks
|
||||
if(account == null || account.id == user?.user_id) {
|
||||
val profileBookmarksFragment = ProfileFeedFragment()
|
||||
val argumentsBookmarks = Bundle().apply {
|
||||
profileBookmarksFragment.arguments = Bundle().apply {
|
||||
putSerializable(Account.ACCOUNT_TAG, account)
|
||||
putSerializable(ProfileFeedFragment.PROFILE_GRID, true)
|
||||
putSerializable(ProfileFeedFragment.BOOKMARKS, true)
|
||||
}
|
||||
profileBookmarksFragment.arguments = argumentsBookmarks
|
||||
return arrayOf(
|
||||
profileGridFragment,
|
||||
profileFeedFragment,
|
||||
profileBookmarksFragment
|
||||
)
|
||||
return returnArray + profileBookmarksFragment
|
||||
}
|
||||
return arrayOf(
|
||||
profileGridFragment,
|
||||
profileFeedFragment
|
||||
)
|
||||
return returnArray
|
||||
}
|
||||
|
||||
private fun setupTabs(
|
||||
tabs: Array<Fragment>
|
||||
tabs: Array<UncachedFeedFragment<FeedContent>>,
|
||||
){
|
||||
binding.viewPager.adapter = object : FragmentStateAdapter(this) {
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
|
@ -117,21 +145,32 @@ class ProfileActivity : BaseThemedWithBarActivity() {
|
|||
tab.tabLabelVisibility = TabLayout.TAB_LABEL_VISIBILITY_UNLABELED
|
||||
when (position) {
|
||||
0 -> {
|
||||
tab.setText("Grid view")
|
||||
tab.setText(R.string.grid_view)
|
||||
tab.setIcon(R.drawable.grid_on_black_24dp)
|
||||
}
|
||||
1 -> {
|
||||
tab.setText("Feed view")
|
||||
tab.setText(R.string.feed_view)
|
||||
tab.setIcon(R.drawable.feed_view)
|
||||
}
|
||||
2 -> {
|
||||
tab.setText("Bookmarks")
|
||||
tab.setText(R.string.collections)
|
||||
tab.setIcon(R.drawable.collections)
|
||||
}
|
||||
3 -> {
|
||||
tab.setText(R.string.bookmarks)
|
||||
tab.setIcon(R.drawable.bookmark)
|
||||
}
|
||||
}
|
||||
}.attach()
|
||||
}
|
||||
|
||||
binding.profileTabs.addOnTabSelectedListener(object : OnTabSelectedListener {
|
||||
override fun onTabSelected(tab: TabLayout.Tab) {}
|
||||
override fun onTabUnselected(tab: TabLayout.Tab) {}
|
||||
override fun onTabReselected(tab: TabLayout.Tab) {
|
||||
tabs[tab.position].onTabReClicked()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun setContent(account: Account?) {
|
||||
if(account != null) {
|
||||
|
@ -142,20 +181,17 @@ class ProfileActivity : BaseThemedWithBarActivity() {
|
|||
val api: PixelfedAPI = apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
val myAccount: Account = try {
|
||||
api.verifyCredentials()
|
||||
} catch (exception: IOException) {
|
||||
} catch (exception: Exception) {
|
||||
Log.e("ProfileActivity:", exception.toString())
|
||||
Toast.makeText(
|
||||
applicationContext, "Could not get your profile",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
return@launchWhenResumed
|
||||
} catch (exception: HttpException) {
|
||||
Toast.makeText(
|
||||
applicationContext, "Could not get your profile",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
return@launchWhenResumed
|
||||
}
|
||||
|
||||
updateUserInfoDb(db, myAccount)
|
||||
|
||||
setViews(myAccount)
|
||||
}
|
||||
}
|
||||
|
@ -187,9 +223,11 @@ class ProfileActivity : BaseThemedWithBarActivity() {
|
|||
|
||||
binding.descriptionTextView.text = parseHTMLText(
|
||||
account.note ?: "", emptyList(), apiHolder,
|
||||
applicationContext,
|
||||
binding.descriptionTextView.context,
|
||||
lifecycleScope
|
||||
)
|
||||
// This is so that the clicks in the text (eg #, @) work.
|
||||
binding.descriptionTextView.movementMethod = LinkMovementMethod.getInstance();
|
||||
|
||||
val displayName = account.getDisplayName()
|
||||
|
||||
|
@ -219,15 +257,17 @@ class ProfileActivity : BaseThemedWithBarActivity() {
|
|||
)
|
||||
}
|
||||
|
||||
private fun onClickEditButton() {
|
||||
val url = "$domain/settings/home"
|
||||
|
||||
if(!openUrl(url)) {
|
||||
Snackbar.make(binding.root, getString(R.string.edit_link_failed),
|
||||
Snackbar.LENGTH_LONG).show()
|
||||
private val editResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (it.resultCode == RESULT_OK) {
|
||||
// Profile was edited, reload
|
||||
setContent(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onClickEditButton() {
|
||||
editResult.launch(Intent(this, EditProfileActivity::class.java))
|
||||
}
|
||||
|
||||
private fun onClickFollowers(account: Account?) {
|
||||
val intent = Intent(this, FollowsActivity::class.java)
|
||||
intent.putExtra(Account.FOLLOWERS_TAG, true)
|
||||
|
@ -297,17 +337,12 @@ class ProfileActivity : BaseThemedWithBarActivity() {
|
|||
val rel = api.follow(account.id.orEmpty())
|
||||
if(rel.following == true) setOnClickUnfollow(account, rel.requested == true)
|
||||
else setOnClickFollow(account)
|
||||
} catch (exception: IOException) {
|
||||
} catch (exception: Exception) {
|
||||
Log.e("FOLLOW ERROR", exception.toString())
|
||||
Toast.makeText(
|
||||
applicationContext, getString(R.string.follow_error),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} catch (exception: HttpException) {
|
||||
Toast.makeText(
|
||||
applicationContext, getString(R.string.follow_error),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -345,7 +380,7 @@ class ProfileActivity : BaseThemedWithBarActivity() {
|
|||
|
||||
setOnClickListener {
|
||||
if(account.locked == true && requested){
|
||||
AlertDialog.Builder(context)
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setMessage(R.string.dialog_message_cancel_follow_request)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
unfollow()
|
||||
|
|
|
@ -6,36 +6,53 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.launch
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.FragmentProfilePostsBinding
|
||||
import org.pixeldroid.app.posts.PostActivity
|
||||
import org.pixeldroid.app.posts.StatusViewHolder
|
||||
import org.pixeldroid.app.posts.feeds.UIMODEL_STATUS_COMPARATOR
|
||||
import org.pixeldroid.app.posts.feeds.uncachedFeeds.*
|
||||
import org.pixeldroid.app.posts.feeds.uncachedFeeds.profile.CollectionsContentRepository
|
||||
import org.pixeldroid.app.posts.feeds.uncachedFeeds.profile.ProfileContentRepository
|
||||
import org.pixeldroid.app.profile.CollectionActivity.Companion.ADD_COLLECTION_TAG
|
||||
import org.pixeldroid.app.profile.CollectionActivity.Companion.ADD_TO_COLLECTION_RESULT
|
||||
import org.pixeldroid.app.profile.CollectionActivity.Companion.DELETE_FROM_COLLECTION_RESULT
|
||||
import org.pixeldroid.app.profile.CollectionActivity.Companion.DELETE_FROM_COLLECTION_TAG
|
||||
import org.pixeldroid.app.utils.BlurHashDecoder
|
||||
import org.pixeldroid.app.utils.api.PixelfedAPI
|
||||
import org.pixeldroid.app.utils.api.objects.Account
|
||||
import org.pixeldroid.app.utils.api.objects.Attachment
|
||||
import org.pixeldroid.app.utils.api.objects.Collection
|
||||
import org.pixeldroid.app.utils.api.objects.FeedContent
|
||||
import org.pixeldroid.app.utils.api.objects.Status
|
||||
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
|
||||
import org.pixeldroid.app.utils.displayDimensionsInPx
|
||||
import org.pixeldroid.app.utils.openUrl
|
||||
import org.pixeldroid.app.utils.setSquareImageFromURL
|
||||
|
||||
/**
|
||||
* Fragment to show a list of [Account]s, as a result of a search.
|
||||
*/
|
||||
class ProfileFeedFragment : UncachedFeedFragment<Status>() {
|
||||
class ProfileFeedFragment : UncachedFeedFragment<FeedContent>() {
|
||||
|
||||
companion object {
|
||||
// List of collections
|
||||
const val COLLECTIONS = "Collections"
|
||||
// Content of collection
|
||||
const val COLLECTION = "Collection"
|
||||
const val COLLECTION_ID = "CollectionId"
|
||||
const val PROFILE_GRID = "ProfileGrid"
|
||||
const val BOOKMARKS = "Bookmarks"
|
||||
}
|
||||
|
@ -44,12 +61,28 @@ class ProfileFeedFragment : UncachedFeedFragment<Status>() {
|
|||
private var user: UserDatabaseEntity? = null
|
||||
private var grid: Boolean = true
|
||||
private var bookmarks: Boolean = false
|
||||
private var collections: Boolean = false
|
||||
private var collection: Collection? = null
|
||||
private var addCollection: Boolean = false
|
||||
private var deleteFromCollection: Boolean = false
|
||||
private var collectionId: String? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
grid = arguments?.getSerializable(PROFILE_GRID) as Boolean
|
||||
bookmarks = arguments?.getSerializable(BOOKMARKS) as Boolean
|
||||
grid = arguments?.getBoolean(PROFILE_GRID, true) ?: true
|
||||
bookmarks = arguments?.getBoolean(BOOKMARKS) ?: false
|
||||
collections = arguments?.getBoolean(COLLECTIONS) ?: false
|
||||
collection = arguments?.getSerializable(COLLECTION) as? Collection
|
||||
addCollection = arguments?.getBoolean(ADD_COLLECTION_TAG) ?: false
|
||||
deleteFromCollection = arguments?.getBoolean(DELETE_FROM_COLLECTION_TAG) ?: false
|
||||
collectionId = arguments?.getString(COLLECTION_ID)
|
||||
if(addCollection){
|
||||
// We want the user's profile, set all the rest to false to be sure
|
||||
collections = false
|
||||
bookmarks = false
|
||||
}
|
||||
|
||||
adapter = ProfilePostsAdapter()
|
||||
|
||||
//get the currently active user
|
||||
|
@ -67,20 +100,23 @@ class ProfileFeedFragment : UncachedFeedFragment<Status>() {
|
|||
|
||||
val view = super.onCreateView(inflater, container, savedInstanceState)
|
||||
|
||||
if(grid || bookmarks) {
|
||||
binding.list.layoutManager = GridLayoutManager(context, 3)
|
||||
if(grid || bookmarks || collections || addCollection) {
|
||||
binding?.list?.layoutManager = GridLayoutManager(context, 3)
|
||||
}
|
||||
|
||||
// Get the view model
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
viewModel = ViewModelProvider(requireActivity(), ProfileViewModelFactory(
|
||||
ProfileContentRepository(
|
||||
(if(!collections) ProfileContentRepository(
|
||||
apiHolder.setToCurrentUser(),
|
||||
accountId,
|
||||
bookmarks
|
||||
bookmarks,
|
||||
if (addCollection) null else collectionId
|
||||
)
|
||||
else CollectionsContentRepository(apiHolder.setToCurrentUser(), accountId)) as UncachedContentRepository<FeedContent>
|
||||
)
|
||||
)[if(bookmarks) "Bookmarks" else "Profile", FeedViewModel::class.java] as FeedViewModel<Status>
|
||||
)[if (addCollection) "AddCollection" else if (collections) "Collections" else if(bookmarks) "Bookmarks" else "Profile",
|
||||
FeedViewModel::class.java] as FeedViewModel<FeedContent>
|
||||
|
||||
launch()
|
||||
initSearch()
|
||||
|
@ -88,29 +124,127 @@ class ProfileFeedFragment : UncachedFeedFragment<Status>() {
|
|||
return view
|
||||
}
|
||||
|
||||
inner class ProfilePostsAdapter() : PagingDataAdapter<Status, RecyclerView.ViewHolder>(
|
||||
UIMODEL_STATUS_COMPARATOR
|
||||
inner class ProfilePostsAdapter : PagingDataAdapter<FeedContent, RecyclerView.ViewHolder>(
|
||||
object : DiffUtil.ItemCallback<FeedContent>() {
|
||||
override fun areItemsTheSame(oldItem: FeedContent, newItem: FeedContent): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: FeedContent, newItem: FeedContent): Boolean =
|
||||
oldItem.id == newItem.id
|
||||
}
|
||||
) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return if(grid || bookmarks) {
|
||||
ProfilePostsViewHolder.create(parent)
|
||||
} else {
|
||||
StatusViewHolder.create(parent)
|
||||
}
|
||||
return if(collections) {
|
||||
if (viewType == 1) {
|
||||
val view =
|
||||
LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.create_new_collection, parent, false)
|
||||
AddCollectionViewHolder(view)
|
||||
} else CollectionsViewHolder.create(parent)
|
||||
}
|
||||
else if(grid || bookmarks) {
|
||||
ProfilePostsViewHolder.create(parent)
|
||||
} else {
|
||||
StatusViewHolder.create(parent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return if(position == 0 && user?.user_id == accountId) 1
|
||||
else 0
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return if (collections && user?.user_id == accountId) {
|
||||
super.getItemCount() + 1
|
||||
} else super.getItemCount()
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
val post = getItem(position)
|
||||
val post = if(collections && user?.user_id == accountId && position == 0) null else getItem(if(collections && user?.user_id == accountId) position - 1 else position)
|
||||
|
||||
post?.let {
|
||||
if(grid || bookmarks) {
|
||||
(holder as ProfilePostsViewHolder).bind(it)
|
||||
if(collections) {
|
||||
(holder as CollectionsViewHolder).bind(it as Collection)
|
||||
} else if(grid || bookmarks || addCollection) {
|
||||
(holder as ProfilePostsViewHolder).bind(
|
||||
it as Status,
|
||||
lifecycleScope,
|
||||
apiHolder.api ?: apiHolder.setToCurrentUser(),
|
||||
addCollection,
|
||||
collection,
|
||||
deleteFromCollection
|
||||
)
|
||||
} else {
|
||||
(holder as StatusViewHolder).bind(it, apiHolder, db,
|
||||
lifecycleScope, requireContext().displayDimensionsInPx())
|
||||
(holder as StatusViewHolder).bind(
|
||||
it as Status, apiHolder, db, lifecycleScope,
|
||||
requireContext().displayDimensionsInPx(), requestPermissionDownloadPic
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if(collections && post == null){
|
||||
(holder as AddCollectionViewHolder).itemView.setOnClickListener {
|
||||
val domain = user?.instance_uri
|
||||
val url = "$domain/i/collections/create"
|
||||
|
||||
if(domain.isNullOrEmpty() || !requireContext().openUrl(url)) {
|
||||
binding?.let { binding ->
|
||||
Snackbar.make(
|
||||
binding.root, getString(R.string.new_collection_link_failed),
|
||||
Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
class AddCollectionViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
|
||||
}
|
||||
|
||||
|
||||
class CollectionsViewHolder(binding: FragmentProfilePostsBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||
private val postPreview: ImageView = binding.postPreview
|
||||
private val albumIcon: ImageView = binding.albumIcon
|
||||
private val videoIcon: ImageView = binding.videoIcon
|
||||
|
||||
fun bind(collection: Collection) {
|
||||
|
||||
if (collection.post_count == 0){
|
||||
//No media in this collection, so put a little icon there
|
||||
postPreview.scaleX = 0.3f
|
||||
postPreview.scaleY = 0.3f
|
||||
Glide.with(postPreview).load(R.drawable.ic_comment_empty).into(postPreview)
|
||||
albumIcon.visibility = View.GONE
|
||||
videoIcon.visibility = View.GONE
|
||||
} else {
|
||||
postPreview.scaleX = 1f
|
||||
postPreview.scaleY = 1f
|
||||
setSquareImageFromURL(postPreview, collection.thumb, postPreview)
|
||||
if (collection.post_count > 1) {
|
||||
albumIcon.visibility = View.VISIBLE
|
||||
} else {
|
||||
albumIcon.visibility = View.GONE
|
||||
}
|
||||
videoIcon.visibility = View.GONE
|
||||
}
|
||||
|
||||
postPreview.setOnClickListener {
|
||||
val intent = Intent(postPreview.context, CollectionActivity::class.java)
|
||||
intent.putExtra(CollectionActivity.COLLECTION_TAG, collection)
|
||||
postPreview.context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(parent: ViewGroup): CollectionsViewHolder {
|
||||
val itemBinding = FragmentProfilePostsBinding.inflate(
|
||||
LayoutInflater.from(parent.context), parent, false
|
||||
)
|
||||
return CollectionsViewHolder(itemBinding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -120,7 +254,9 @@ class ProfilePostsViewHolder(binding: FragmentProfilePostsBinding) : RecyclerVie
|
|||
private val albumIcon: ImageView = binding.albumIcon
|
||||
private val videoIcon: ImageView = binding.videoIcon
|
||||
|
||||
fun bind(post: Status) {
|
||||
fun bind(post: Status, lifecycleScope: LifecycleCoroutineScope, api: PixelfedAPI,
|
||||
addCollection: Boolean = false, collection: Collection? = null, deleteFromCollection: Boolean = false
|
||||
) {
|
||||
|
||||
if ((post.media_attachments?.size ?: 0) == 0){
|
||||
//No media in this post, so put a little icon there
|
||||
|
@ -158,9 +294,46 @@ class ProfilePostsViewHolder(binding: FragmentProfilePostsBinding) : RecyclerVie
|
|||
}
|
||||
|
||||
postPreview.setOnClickListener {
|
||||
val intent = Intent(postPreview.context, PostActivity::class.java)
|
||||
intent.putExtra(Status.POST_TAG, post)
|
||||
postPreview.context.startActivity(intent)
|
||||
if(addCollection && collection != null){
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
api.addToCollection(collection.id, post.id)
|
||||
val intent = Intent(postPreview.context, CollectionActivity::class.java)
|
||||
.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
putExtra(ADD_TO_COLLECTION_RESULT, true)
|
||||
putExtra(CollectionActivity.COLLECTION_TAG, collection)
|
||||
}
|
||||
postPreview.context.startActivity(intent)
|
||||
} catch (exception: Exception) {
|
||||
Snackbar.make(postPreview, postPreview.context.getString(R.string.error_add_post_to_collection),
|
||||
Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
} else if (deleteFromCollection && (collection != null)){
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
api.removeFromCollection(collection.id, post.id)
|
||||
val intent = Intent(postPreview.context, CollectionActivity::class.java)
|
||||
.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
putExtra(DELETE_FROM_COLLECTION_RESULT, true)
|
||||
putExtra(CollectionActivity.COLLECTION_TAG, collection)
|
||||
}
|
||||
postPreview.context.startActivity(intent)
|
||||
} catch (exception: Exception) {
|
||||
Snackbar.make(postPreview, postPreview.context.getString(R.string.error_remove_post_from_collection),
|
||||
Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
val intent = Intent(postPreview.context, PostActivity::class.java)
|
||||
intent.putExtra(Status.POST_TAG, post)
|
||||
postPreview.context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -176,7 +349,7 @@ class ProfilePostsViewHolder(binding: FragmentProfilePostsBinding) : RecyclerVie
|
|||
|
||||
|
||||
class ProfileViewModelFactory @ExperimentalPagingApi constructor(
|
||||
private val searchContentRepository: UncachedContentRepository<Status>
|
||||
private val searchContentRepository: UncachedContentRepository<FeedContent>
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
|
|
|
@ -1,12 +1,22 @@
|
|||
package org.pixeldroid.app.profile
|
||||
|
||||
import android.view.View
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.FragmentProfilePostsBinding
|
||||
|
||||
class ProfilePostViewHolder(val postView: View) : RecyclerView.ViewHolder(postView) {
|
||||
val postPreview: ImageView = postView.findViewById(R.id.postPreview)
|
||||
val albumIcon: ImageView = postView.findViewById(R.id.albumIcon)
|
||||
val videoIcon: ImageView = postView.findViewById(R.id.albumIcon)
|
||||
class ProfilePostViewHolder(val postView: FragmentProfilePostsBinding) : RecyclerView.ViewHolder(postView.root) {
|
||||
val postPreview: ImageView = postView.postPreview
|
||||
val albumIcon: ImageView = postView.albumIcon
|
||||
val videoIcon: ImageView = postView.videoIcon
|
||||
|
||||
companion object {
|
||||
fun create(parent: ViewGroup): ProfilePostViewHolder {
|
||||
val itemBinding = FragmentProfilePostsBinding.inflate(
|
||||
LayoutInflater.from(parent.context), parent, false
|
||||
)
|
||||
return ProfilePostViewHolder(itemBinding)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,17 +9,21 @@ import androidx.viewpager2.widget.ViewPager2
|
|||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.ActivitySearchBinding
|
||||
import org.pixeldroid.app.posts.feeds.uncachedFeeds.UncachedPostsFragment
|
||||
import org.pixeldroid.app.posts.feeds.uncachedFeeds.search.SearchAccountFragment
|
||||
import org.pixeldroid.app.posts.feeds.uncachedFeeds.search.SearchHashtagFragment
|
||||
import org.pixeldroid.app.utils.BaseThemedWithBarActivity
|
||||
import org.pixeldroid.app.utils.BaseActivity
|
||||
import org.pixeldroid.app.utils.api.objects.Results
|
||||
|
||||
class SearchActivity : BaseThemedWithBarActivity() {
|
||||
class SearchActivity : BaseActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_search)
|
||||
val binding = ActivitySearchBinding.inflate(layoutInflater)
|
||||
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.topBar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
var query = ""
|
||||
|
|
|
@ -7,37 +7,28 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.pixeldroid.app.R
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.pixeldroid.app.databinding.FragmentSearchBinding
|
||||
import org.pixeldroid.app.profile.ProfilePostViewHolder
|
||||
import org.pixeldroid.app.utils.api.PixelfedAPI
|
||||
import org.pixeldroid.app.utils.api.objects.Status
|
||||
import org.pixeldroid.app.posts.PostActivity
|
||||
import org.pixeldroid.app.searchDiscover.TrendingActivity.Companion.TRENDING_TAG
|
||||
import org.pixeldroid.app.searchDiscover.TrendingActivity.Companion.TrendingType
|
||||
import org.pixeldroid.app.utils.BaseFragment
|
||||
import org.pixeldroid.app.utils.api.objects.Attachment
|
||||
import org.pixeldroid.app.utils.api.PixelfedAPI
|
||||
import org.pixeldroid.app.utils.bindingLifecycleAware
|
||||
import org.pixeldroid.app.utils.setSquareImageFromURL
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
|
||||
|
||||
/**
|
||||
* This fragment lets you search and use Pixelfed's Discover feature
|
||||
*/
|
||||
|
||||
class SearchDiscoverFragment : BaseFragment() {
|
||||
|
||||
private lateinit var api: PixelfedAPI
|
||||
private lateinit var recycler : RecyclerView
|
||||
private lateinit var adapter : DiscoverRecyclerViewAdapter
|
||||
|
||||
var binding: FragmentSearchBinding by bindingLifecycleAware()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
binding = FragmentSearchBinding.inflate(inflater, container, false)
|
||||
|
||||
|
@ -48,12 +39,6 @@ class SearchDiscoverFragment : BaseFragment() {
|
|||
isSubmitButtonEnabled = true
|
||||
}
|
||||
|
||||
// Set posts RecyclerView as a grid with 3 columns
|
||||
recycler = binding.discoverList
|
||||
recycler.layoutManager = GridLayoutManager(requireContext(), 3)
|
||||
adapter = DiscoverRecyclerViewAdapter()
|
||||
recycler.adapter = adapter
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
|
@ -62,78 +47,16 @@ class SearchDiscoverFragment : BaseFragment() {
|
|||
|
||||
api = apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
|
||||
getDiscover()
|
||||
|
||||
binding.discoverRefreshLayout.setOnRefreshListener {
|
||||
getDiscover()
|
||||
}
|
||||
binding.discoverCardView.setOnClickListener { onClickCardView(TrendingType.DISCOVER) }
|
||||
binding.trendingCardView.setOnClickListener { onClickCardView(TrendingType.POSTS) }
|
||||
binding.hashtagsCardView.setOnClickListener { onClickCardView(TrendingType.HASHTAGS) }
|
||||
binding.accountsCardView.setOnClickListener { onClickCardView(TrendingType.ACCOUNTS) }
|
||||
}
|
||||
|
||||
private fun showError(@StringRes errorText: Int = R.string.loading_toast, show: Boolean = true){
|
||||
binding.motionLayout.apply {
|
||||
if(show){
|
||||
transitionToEnd()
|
||||
} else {
|
||||
transitionToStart()
|
||||
}
|
||||
}
|
||||
binding.discoverRefreshLayout.isRefreshing = false
|
||||
binding.discoverProgressBar.visibility = View.GONE
|
||||
private fun onClickCardView(type: TrendingType) {
|
||||
val intent = Intent(requireContext(), TrendingActivity::class.java)
|
||||
intent.putExtra(TRENDING_TAG, type)
|
||||
ContextCompat.startActivity(binding.root.context, intent, null)
|
||||
}
|
||||
|
||||
|
||||
private fun getDiscover() {
|
||||
lifecycleScope.launchWhenCreated {
|
||||
try {
|
||||
val discoverPosts = api.discover()
|
||||
adapter.addPosts(discoverPosts.posts)
|
||||
binding.discoverNoInfiniteLoad.visibility = View.VISIBLE
|
||||
showError(show = false)
|
||||
} catch (exception: IOException) {
|
||||
showError()
|
||||
} catch (exception: HttpException) {
|
||||
showError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [RecyclerView.Adapter] that can display a list of [Status]s' thumbnails for the discover view
|
||||
*/
|
||||
class DiscoverRecyclerViewAdapter: RecyclerView.Adapter<ProfilePostViewHolder>() {
|
||||
private val posts: ArrayList<Status?> = ArrayList()
|
||||
|
||||
fun addPosts(newPosts : List<Status>) {
|
||||
posts.clear()
|
||||
posts.addAll(newPosts)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProfilePostViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.fragment_profile_posts, parent, false)
|
||||
return ProfilePostViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ProfilePostViewHolder, position: Int) {
|
||||
val post = posts[position]
|
||||
if((post?.media_attachments?.size ?: 0) > 1) {
|
||||
holder.albumIcon.visibility = View.VISIBLE
|
||||
} else {
|
||||
holder.albumIcon.visibility = View.GONE
|
||||
if(post?.media_attachments?.getOrNull(0)?.type == Attachment.AttachmentType.video) {
|
||||
holder.videoIcon.visibility = View.VISIBLE
|
||||
} else holder.videoIcon.visibility = View.GONE
|
||||
|
||||
}
|
||||
setSquareImageFromURL(holder.postView, post?.getPostPreviewURL(), holder.postPreview, post?.media_attachments?.firstOrNull()?.blurhash)
|
||||
holder.postPreview.setOnClickListener {
|
||||
val intent = Intent(holder.postView.context, PostActivity::class.java)
|
||||
intent.putExtra(Status.POST_TAG, post)
|
||||
holder.postView.context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = posts.size
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,189 @@
|
|||
package org.pixeldroid.app.searchDiscover
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.ActivityTrendingBinding
|
||||
import org.pixeldroid.app.posts.PostActivity
|
||||
import org.pixeldroid.app.posts.feeds.uncachedFeeds.accountLists.AccountViewHolder
|
||||
import org.pixeldroid.app.posts.feeds.uncachedFeeds.search.HashTagViewHolder
|
||||
import org.pixeldroid.app.profile.ProfilePostViewHolder
|
||||
import org.pixeldroid.app.utils.BaseActivity
|
||||
import org.pixeldroid.app.utils.api.PixelfedAPI
|
||||
import org.pixeldroid.app.utils.api.objects.Account
|
||||
import org.pixeldroid.app.utils.api.objects.Attachment
|
||||
import org.pixeldroid.app.utils.api.objects.FeedContent
|
||||
import org.pixeldroid.app.utils.api.objects.Status
|
||||
import org.pixeldroid.app.utils.api.objects.Tag
|
||||
import org.pixeldroid.app.utils.setSquareImageFromURL
|
||||
|
||||
class TrendingActivity : BaseActivity() {
|
||||
|
||||
private lateinit var binding: ActivityTrendingBinding
|
||||
private lateinit var trendingAdapter : TrendingRecyclerViewAdapter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityTrendingBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.topBar)
|
||||
|
||||
val recycler = binding.list
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
val type = intent.getSerializableExtra(TRENDING_TAG) as TrendingType? ?: TrendingType.POSTS
|
||||
|
||||
when (type) {
|
||||
TrendingType.POSTS, TrendingType.DISCOVER -> {
|
||||
// Set posts RecyclerView as a grid with 3 columns
|
||||
recycler.layoutManager = GridLayoutManager(this, 3)
|
||||
supportActionBar?.setTitle(
|
||||
if (type == TrendingType.POSTS) {
|
||||
R.string.trending_posts
|
||||
} else {
|
||||
R.string.discover
|
||||
}
|
||||
)
|
||||
this.trendingAdapter = DiscoverRecyclerViewAdapter()
|
||||
}
|
||||
TrendingType.HASHTAGS -> {
|
||||
supportActionBar?.setTitle(R.string.trending_hashtags)
|
||||
this.trendingAdapter = HashtagsRecyclerViewAdapter()
|
||||
}
|
||||
TrendingType.ACCOUNTS -> {
|
||||
supportActionBar?.setTitle(R.string.popular_accounts)
|
||||
this.trendingAdapter = AccountsRecyclerViewAdapter()
|
||||
}
|
||||
}
|
||||
recycler.adapter = this.trendingAdapter
|
||||
|
||||
getTrending(type)
|
||||
binding.refreshLayout.setOnRefreshListener {
|
||||
getTrending(type)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showError(@StringRes errorText: Int = R.string.loading_toast, show: Boolean = true){
|
||||
binding.motionLayout.apply {
|
||||
if(show){
|
||||
transitionToEnd()
|
||||
binding.errorLayout.errorText.setText(errorText)
|
||||
} else {
|
||||
transitionToStart()
|
||||
}
|
||||
}
|
||||
binding.refreshLayout.isRefreshing = false
|
||||
binding.progressBar.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun getTrending(type: TrendingType) {
|
||||
lifecycleScope.launchWhenCreated {
|
||||
try {
|
||||
val api: PixelfedAPI = apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
val content: List<FeedContent> = when(type) {
|
||||
TrendingType.POSTS -> api.trendingPosts(Range.daily)
|
||||
TrendingType.HASHTAGS -> api.trendingHashtags().map { it.copy(name = it.name.removePrefix("#")) }
|
||||
TrendingType.ACCOUNTS -> api.popularAccounts()
|
||||
TrendingType.DISCOVER -> api.discover().posts
|
||||
}
|
||||
trendingAdapter.addPosts(content)
|
||||
showError(show = false)
|
||||
} catch (exception: Exception) {
|
||||
showError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract class for the different RecyclerViewAdapters used in this activity
|
||||
*/
|
||||
abstract class TrendingRecyclerViewAdapter: RecyclerView.Adapter<RecyclerView.ViewHolder>(){
|
||||
val data: ArrayList<FeedContent?> = ArrayList()
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun addPosts(newPosts: List<FeedContent>){
|
||||
data.clear()
|
||||
data.addAll(newPosts)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = data.size
|
||||
}
|
||||
|
||||
/**
|
||||
* [RecyclerView.Adapter] that can display a list of [Status]s' thumbnails for the discover view
|
||||
*/
|
||||
class DiscoverRecyclerViewAdapter: TrendingRecyclerViewAdapter() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProfilePostViewHolder =
|
||||
ProfilePostViewHolder.create(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
if (holder !is ProfilePostViewHolder) return
|
||||
|
||||
val post = data[position] as? Status
|
||||
if((post?.media_attachments?.size ?: 0) > 1) {
|
||||
holder.albumIcon.visibility = View.VISIBLE
|
||||
} else {
|
||||
holder.albumIcon.visibility = View.GONE
|
||||
if(post?.media_attachments?.getOrNull(0)?.type == Attachment.AttachmentType.video) {
|
||||
holder.videoIcon.visibility = View.VISIBLE
|
||||
} else holder.videoIcon.visibility = View.GONE
|
||||
|
||||
}
|
||||
setSquareImageFromURL(holder.postView.root, post?.getPostPreviewURL(), holder.postPreview, post?.media_attachments?.firstOrNull()?.blurhash)
|
||||
holder.postPreview.setOnClickListener {
|
||||
val intent = Intent(holder.postView.root.context, PostActivity::class.java)
|
||||
intent.putExtra(Status.POST_TAG, post)
|
||||
holder.postView.root.context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TRENDING_TAG = "TrendingTag"
|
||||
|
||||
enum class TrendingType {
|
||||
POSTS, HASHTAGS, ACCOUNTS, DISCOVER
|
||||
}
|
||||
|
||||
@Suppress("EnumEntryName", "unused")
|
||||
enum class Range {
|
||||
daily, monthly, yearly
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* [RecyclerView.Adapter] that can display a list of [Tag]s for the trending view
|
||||
*/
|
||||
class HashtagsRecyclerViewAdapter: TrendingRecyclerViewAdapter() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HashTagViewHolder =
|
||||
HashTagViewHolder.create(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
val tag = data[position] as Tag
|
||||
(holder as HashTagViewHolder).bind(tag)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* [RecyclerView.Adapter] that can display a list of [Account]s for the popular view
|
||||
*/
|
||||
class AccountsRecyclerViewAdapter: TrendingRecyclerViewAdapter() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder =
|
||||
AccountViewHolder.create(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
val account = data[position] as? Account
|
||||
(holder as AccountViewHolder).bind(account)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
package org.pixeldroid.app.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import org.pixeldroid.app.BuildConfig
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.ActivityAboutBinding
|
||||
import org.pixeldroid.app.utils.BaseThemedWithBarActivity
|
||||
|
||||
class AboutActivity : BaseThemedWithBarActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val binding = ActivityAboutBinding.inflate(layoutInflater)
|
||||
|
||||
setContentView(binding.root)
|
||||
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setTitle(R.string.about_pixeldroid)
|
||||
|
||||
binding.aboutVersionNumber.text = BuildConfig.VERSION_NAME
|
||||
binding.licensesButton.setOnClickListener{
|
||||
val intent = Intent(this, LicenseActivity::class.java)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
package org.pixeldroid.app.settings
|
||||
|
||||
import android.os.Bundle
|
||||
import com.google.gson.Gson
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.OpenSourceLicenseBinding
|
||||
import org.pixeldroid.app.settings.licenseObjects.Libraries
|
||||
import org.pixeldroid.app.settings.licenseObjects.OpenSourceItem
|
||||
import org.pixeldroid.app.utils.BaseThemedWithBarActivity
|
||||
|
||||
/**
|
||||
* Displays licenses for all app dependencies. JSON is
|
||||
* generated by the plugin https://github.com/cookpad/LicenseToolsPlugin.
|
||||
*/
|
||||
class LicenseActivity: BaseThemedWithBarActivity() {
|
||||
|
||||
private lateinit var binding: OpenSourceLicenseBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = OpenSourceLicenseBinding.inflate(layoutInflater)
|
||||
|
||||
setContentView(binding.root)
|
||||
|
||||
supportActionBar?.setTitle(R.string.dependencies_licenses)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setHomeButtonEnabled(true)
|
||||
|
||||
setupRecyclerView()
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
val text: String = applicationContext.assets.open("licenses.json")
|
||||
.bufferedReader().use { it.readText() }
|
||||
|
||||
val listObj: List<OpenSourceItem> = Gson().fromJson(text, Libraries::class.java).libraries
|
||||
|
||||
val adapter = OpenSourceLicenseAdapter(listObj)
|
||||
binding.openSourceLicenseRecyclerView.adapter = adapter
|
||||
}
|
||||
}
|
|
@ -1,63 +0,0 @@
|
|||
package org.pixeldroid.app.settings
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.pixeldroid.app.databinding.OpenSourceItemBinding
|
||||
import org.pixeldroid.app.settings.licenseObjects.OpenSourceItem
|
||||
|
||||
class OpenSourceLicenseAdapter(private val openSourceItems: List<OpenSourceItem>) :
|
||||
RecyclerView.Adapter<OpenSourceLicenseAdapter.OpenSourceLicenceViewHolder>() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): OpenSourceLicenceViewHolder
|
||||
{
|
||||
val itemBinding = OpenSourceItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return OpenSourceLicenceViewHolder(itemBinding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: OpenSourceLicenceViewHolder, position: Int) {
|
||||
val item = openSourceItems[position]
|
||||
holder.bind(item)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = openSourceItems.size
|
||||
|
||||
class OpenSourceLicenceViewHolder(val binding: OpenSourceItemBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
@SuppressLint("SetTextI18n")
|
||||
fun bind(item: OpenSourceItem) {
|
||||
with(binding) {
|
||||
if (!item.libraryName.isNullOrEmpty()) {
|
||||
title.isVisible = true
|
||||
title.text = "${item.libraryName}"
|
||||
} else {
|
||||
title.isVisible = false
|
||||
}
|
||||
val license = item.license
|
||||
if (license != null) {
|
||||
val licenseUrl = item.licenseUrl?.let { " (${it} )" } ?: ""
|
||||
copyright.isVisible = true
|
||||
copyright.apply {
|
||||
text = "$license$licenseUrl"
|
||||
movementMethod = LinkMovementMethod.getInstance()
|
||||
}
|
||||
} else {
|
||||
copyright.isVisible = false
|
||||
}
|
||||
if (item.url != null || item.copyrightHolder != null) {
|
||||
val licenseUrl = item.url?.let { " (${it} )" } ?: ""
|
||||
url.isVisible = true
|
||||
url.apply {
|
||||
text = "${item.copyrightHolder ?: ""}$licenseUrl"
|
||||
movementMethod = LinkMovementMethod.getInstance()
|
||||
}
|
||||
} else {
|
||||
url.isVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,31 +1,55 @@
|
|||
package org.pixeldroid.app.settings
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.XmlResourceParser
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.addCallback
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.pixeldroid.app.MainActivity
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.utils.BaseThemedWithBarActivity
|
||||
import org.pixeldroid.app.databinding.SettingsBinding
|
||||
import org.pixeldroid.common.ThemedActivity
|
||||
import org.pixeldroid.app.utils.setThemeFromPreferences
|
||||
|
||||
class SettingsActivity : BaseThemedWithBarActivity(), SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
class SettingsActivity : ThemedActivity(), SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
private var restartMainOnExit = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val binding = SettingsBinding.inflate(layoutInflater)
|
||||
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.topBar)
|
||||
|
||||
setContentView(R.layout.settings)
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.settings, SettingsFragment())
|
||||
.commit()
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setTitle(R.string.menu_settings)
|
||||
|
||||
onBackPressedDispatcher.addCallback(this /* lifecycle owner */) {
|
||||
// Handle the back button event
|
||||
// If a setting (for example language or theme) was changed, the main activity should be
|
||||
// started without history so that the change is applied to the whole back stack
|
||||
if (restartMainOnExit) {
|
||||
val intent = Intent(this@SettingsActivity, MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
super@SettingsActivity.startActivity(intent)
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
restartMainOnExit = intent.getBooleanExtra("restartMain", false)
|
||||
}
|
||||
|
@ -44,28 +68,17 @@ class SettingsActivity : BaseThemedWithBarActivity(), SharedPreferences.OnShared
|
|||
)
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
// If a setting (for example language or theme) was changed, the main activity should be
|
||||
// started without history so that the change is applied to the whole back stack
|
||||
if (restartMainOnExit) {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
super.startActivity(intent)
|
||||
} else {
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
when (key) {
|
||||
"language" -> {
|
||||
recreateWithRestartStatus()
|
||||
}
|
||||
"theme" -> {
|
||||
setThemeFromPreferences(sharedPreferences, resources)
|
||||
recreateWithRestartStatus()
|
||||
}
|
||||
"themeColor" -> {
|
||||
recreateWithRestartStatus()
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
sharedPreferences?.let {
|
||||
when (key) {
|
||||
"theme" -> {
|
||||
setThemeFromPreferences(it, resources)
|
||||
recreateWithRestartStatus()
|
||||
}
|
||||
|
||||
"themeColor" -> {
|
||||
recreateWithRestartStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -88,6 +101,8 @@ class SettingsActivity : BaseThemedWithBarActivity(), SharedPreferences.OnShared
|
|||
var dialogFragment: DialogFragment? = null
|
||||
if (preference is ColorPreference) {
|
||||
dialogFragment = ColorPreferenceDialog((preference as ColorPreference?)!!)
|
||||
} else if(preference.key == "language"){
|
||||
dialogFragment = LanguageSettingFragment()
|
||||
}
|
||||
if (dialogFragment != null) {
|
||||
dialogFragment.setTargetFragment(this, 0)
|
||||
|
@ -100,12 +115,63 @@ class SettingsActivity : BaseThemedWithBarActivity(), SharedPreferences.OnShared
|
|||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.root_preferences, rootKey)
|
||||
|
||||
findPreference<ListPreference>("language")?.let {
|
||||
it.setSummaryProvider {
|
||||
val locale = AppCompatDelegate.getApplicationLocales().get(0)
|
||||
locale?.getDisplayName(locale) ?: getString(R.string.default_system)
|
||||
}
|
||||
}
|
||||
|
||||
//Hide Notification setting for Android versions where it doesn't work
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
preferenceManager.findPreference<Preference>("notification")
|
||||
findPreference<Preference>("notification")
|
||||
?.let { preferenceScreen.removePreference(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
class LanguageSettingFragment : DialogFragment() {
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val list: MutableList<String> = mutableListOf()
|
||||
// IDE doesn't find it, but compiling works apparently?
|
||||
resources.getXml(R.xml._generated_res_locale_config).use {
|
||||
var eventType = it.eventType
|
||||
while (eventType != XmlResourceParser.END_DOCUMENT) {
|
||||
when (eventType) {
|
||||
XmlResourceParser.START_TAG -> {
|
||||
if (it.name == "locale") {
|
||||
list.add(it.getAttributeValue(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
eventType = it.next()
|
||||
}
|
||||
}
|
||||
val locales = AppCompatDelegate.getApplicationLocales()
|
||||
val checkedItem: Int =
|
||||
if(locales.isEmpty) 0
|
||||
else {
|
||||
// For some reason we get a bit inconsistent language tags. This normalises it for
|
||||
// the currently used languages, but it might break in the future if we add some
|
||||
val index = list.indexOf(locales.get(0)?.toLanguageTag()?.lowercase()?.replace('_', '-'))
|
||||
// If found, we want to compensate for the first in the list being the default
|
||||
if(index == -1) -1
|
||||
else index + 1
|
||||
}
|
||||
|
||||
return MaterialAlertDialogBuilder(requireContext()).apply {
|
||||
setIcon(R.drawable.translate_black_24dp)
|
||||
setTitle(R.string.language)
|
||||
setSingleChoiceItems((mutableListOf(getString(R.string.default_system)) + list.map {
|
||||
val appLocale = LocaleListCompat.forLanguageTags(it)
|
||||
appLocale.get(0)!!.getDisplayName(appLocale.get(0)!!)
|
||||
}).toTypedArray(), checkedItem) { dialog, which ->
|
||||
val languageTag = if(which in 1..list.size) list[which - 1] else null
|
||||
dialog.dismiss()
|
||||
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(languageTag))
|
||||
}
|
||||
setNegativeButton(android.R.string.ok) { _, _ -> }
|
||||
}.create()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
package org.pixeldroid.app.settings.licenseObjects
|
||||
|
||||
data class Libraries(
|
||||
val libraries: List<OpenSourceItem>
|
||||
)
|
|
@ -1,10 +0,0 @@
|
|||
package org.pixeldroid.app.settings.licenseObjects
|
||||
|
||||
|
||||
data class OpenSourceItem(
|
||||
val libraryName: String?,
|
||||
val copyrightHolder: String?,
|
||||
val url: String?,
|
||||
val license: String?,
|
||||
val licenseUrl: String?,
|
||||
)
|
|
@ -0,0 +1,215 @@
|
|||
package org.pixeldroid.app.stories
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Bundle
|
||||
import android.view.MotionEvent
|
||||
import android.view.View.OnClickListener
|
||||
import android.view.View.OnTouchListener
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
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.RequestOptions
|
||||
import com.bumptech.glide.request.target.Target
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.launch
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.ActivityStoriesBinding
|
||||
import org.pixeldroid.app.posts.setTextViewFromISO8601
|
||||
import org.pixeldroid.app.utils.BaseActivity
|
||||
import org.pixeldroid.app.utils.api.objects.Account
|
||||
|
||||
class StoriesActivity: BaseActivity() {
|
||||
|
||||
companion object {
|
||||
const val STORY_CAROUSEL = "LaunchStoryCarousel"
|
||||
const val STORY_CAROUSEL_SELF = "LaunchStoryCarouselSelf"
|
||||
const val STORY_CAROUSEL_USER_ID = "LaunchStoryUserId"
|
||||
}
|
||||
|
||||
private lateinit var binding: ActivityStoriesBinding
|
||||
|
||||
private lateinit var storyProgress: StoryProgress
|
||||
|
||||
private val model: StoriesViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
//force night mode always
|
||||
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
binding = ActivityStoriesBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
storyProgress = StoryProgress(model.uiState.value.imageList.size)
|
||||
binding.storyProgressImage.setImageDrawable(storyProgress)
|
||||
|
||||
lifecycleScope.launch {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
model.uiState.collect { uiState ->
|
||||
binding.pause.isSelected = uiState.paused
|
||||
|
||||
uiState.age?.let { setTextViewFromISO8601(it, binding.storyAge, false) }
|
||||
|
||||
if (uiState.errorMessage != null) {
|
||||
binding.storyErrorText.setText(uiState.errorMessage)
|
||||
binding.storyErrorCard.isVisible = true
|
||||
} else binding.storyErrorCard.isVisible = false
|
||||
|
||||
if (uiState.snackBar != null) {
|
||||
Snackbar.make(
|
||||
binding.root, uiState.snackBar,
|
||||
Snackbar.LENGTH_SHORT
|
||||
).setAnchorView(binding.storyReplyField).show()
|
||||
model.shownSnackbar()
|
||||
}
|
||||
|
||||
if (uiState.username != null) {
|
||||
binding.storyReplyField.hint = getString(R.string.replyToStory).format(uiState.username)
|
||||
} else binding.storyReplyField.hint = null
|
||||
|
||||
uiState.profilePicture?.let {
|
||||
Glide.with(binding.storyAuthorProfilePicture)
|
||||
.load(it)
|
||||
.apply(RequestOptions.circleCropTransform())
|
||||
.into(binding.storyAuthorProfilePicture)
|
||||
}
|
||||
|
||||
binding.storyAuthor.text = uiState.username
|
||||
|
||||
storyProgress.currentStory = uiState.currentImage
|
||||
|
||||
uiState.imageList.getOrNull(uiState.currentImage)?.let {
|
||||
Glide.with(binding.storyImage)
|
||||
.load(it)
|
||||
.listener(object : RequestListener<Drawable> {
|
||||
override fun onLoadFailed(
|
||||
e: GlideException?,
|
||||
model: Any?,
|
||||
target: Target<Drawable>,
|
||||
isFirstResource: Boolean,
|
||||
): Boolean = false
|
||||
|
||||
override fun onResourceReady(
|
||||
resource: Drawable,
|
||||
model: Any,
|
||||
target: Target<Drawable>?,
|
||||
dataSource: DataSource,
|
||||
isFirstResource: Boolean,
|
||||
): Boolean {
|
||||
Glide.with(binding.storyImage)
|
||||
.load(uiState.imageList.getOrNull(uiState.currentImage + 1))
|
||||
.preload()
|
||||
return false
|
||||
}
|
||||
})
|
||||
.into(binding.storyImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Pause when clicked on text field
|
||||
binding.storyReplyField.editText?.setOnFocusChangeListener { view, isFocused ->
|
||||
if (view.isInTouchMode && isFocused) {
|
||||
view.performClick() // picks up first tap
|
||||
}
|
||||
}
|
||||
binding.storyReplyField.editText?.setOnClickListener {
|
||||
if (!model.uiState.value.paused) {
|
||||
model.pause()
|
||||
}
|
||||
}
|
||||
|
||||
binding.storyReplyField.editText?.doAfterTextChanged {
|
||||
it?.let { text ->
|
||||
val string = text.toString()
|
||||
if(string != model.uiState.value.reply) model.replyChanged(string)
|
||||
}
|
||||
}
|
||||
|
||||
binding.storyReplyField.setEndIconOnClickListener {
|
||||
binding.storyReplyField.editText?.text?.let { text ->
|
||||
model.sendReply(text)
|
||||
}
|
||||
}
|
||||
|
||||
binding.storyErrorCard.setOnClickListener{
|
||||
model.dismissError()
|
||||
}
|
||||
|
||||
model.count.observe(this) { state ->
|
||||
// Render state in UI
|
||||
model.uiState.value.durationList.getOrNull(model.uiState.value.currentImage)?.let {
|
||||
storyProgress.progress = 1 - (state/it.toFloat())
|
||||
binding.storyProgressImage.postInvalidate()
|
||||
}
|
||||
}
|
||||
|
||||
binding.pause.setOnClickListener {
|
||||
//Set the button's appearance
|
||||
it.isSelected = !it.isSelected
|
||||
model.pause()
|
||||
}
|
||||
|
||||
val authorOnClickListener = OnClickListener {
|
||||
if (!model.uiState.value.paused) {
|
||||
model.pause()
|
||||
}
|
||||
model.currentProfileId()?.let {
|
||||
lifecycleScope.launch {
|
||||
Account.openAccountFromId(
|
||||
it,
|
||||
apiHolder.api ?: apiHolder.setToCurrentUser(),
|
||||
this@StoriesActivity
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.storyAuthorProfilePicture.setOnClickListener(authorOnClickListener)
|
||||
binding.storyAuthor.setOnClickListener(authorOnClickListener)
|
||||
|
||||
val onTouchListener = OnTouchListener { v, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> if (!model.uiState.value.paused) {
|
||||
model.pause()
|
||||
}
|
||||
MotionEvent.ACTION_UP -> if(event.eventTime - event.downTime < 500) {
|
||||
v.performClick()
|
||||
return@OnTouchListener false
|
||||
} else model.pause()
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
binding.viewMiddle.setOnTouchListener{ v, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> model.pause()
|
||||
MotionEvent.ACTION_UP -> if(event.eventTime - event.downTime < 500) {
|
||||
v.performClick()
|
||||
return@setOnTouchListener false
|
||||
} else model.pause()
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
binding.viewLeft.setOnTouchListener(onTouchListener)
|
||||
binding.viewRight.setOnTouchListener(onTouchListener)
|
||||
|
||||
binding.viewRight.setOnClickListener {
|
||||
model.goToNext()
|
||||
}
|
||||
binding.viewLeft.setOnClickListener {
|
||||
model.goToPrevious()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,210 @@
|
|||
package org.pixeldroid.app.stories
|
||||
|
||||
import android.os.CountDownTimer
|
||||
import android.text.Editable
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.utils.api.objects.CarouselUserContainer
|
||||
import org.pixeldroid.app.utils.api.objects.Story
|
||||
import org.pixeldroid.app.utils.api.objects.StoryCarousel
|
||||
import org.pixeldroid.app.utils.db.AppDatabase
|
||||
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class StoriesUiState(
|
||||
val profilePicture: String? = null,
|
||||
val username: String? = null,
|
||||
val age: Instant? = null,
|
||||
val currentImage: Int = 0,
|
||||
val imageList: List<String> = emptyList(),
|
||||
val durationList: List<Int> = emptyList(),
|
||||
val paused: Boolean = false,
|
||||
@StringRes
|
||||
val errorMessage: Int? = null,
|
||||
@StringRes
|
||||
val snackBar: Int? = null,
|
||||
val reply: String = ""
|
||||
)
|
||||
@HiltViewModel
|
||||
class StoriesViewModel @Inject constructor(state: SavedStateHandle,
|
||||
db: AppDatabase,
|
||||
private val apiHolder: PixelfedAPIHolder) : ViewModel() {
|
||||
private val carousel: StoryCarousel? = state[StoriesActivity.STORY_CAROUSEL]
|
||||
private val userId: String? = state[StoriesActivity.STORY_CAROUSEL_USER_ID]
|
||||
private val selfCarousel: Array<Story>? = state[StoriesActivity.STORY_CAROUSEL_SELF]
|
||||
|
||||
private var currentAccount: CarouselUserContainer?
|
||||
|
||||
private val _uiState: MutableStateFlow<StoriesUiState>
|
||||
|
||||
val uiState: StateFlow<StoriesUiState>
|
||||
|
||||
val count = MutableLiveData<Float>()
|
||||
|
||||
private var timer: CountDownTimer? = null
|
||||
|
||||
init {
|
||||
currentAccount =
|
||||
if (selfCarousel != null) {
|
||||
db.userDao().getActiveUser()?.let { CarouselUserContainer(it, selfCarousel.toList()) }
|
||||
} else carousel?.nodes?.firstOrNull { it?.user?.id == userId }
|
||||
|
||||
_uiState = MutableStateFlow(newUiStateFromCurrentAccount())
|
||||
uiState = _uiState
|
||||
|
||||
startTimerForCurrent()
|
||||
}
|
||||
|
||||
private fun setTimer(timerLength: Float) {
|
||||
count.value = timerLength
|
||||
timer = object: CountDownTimer((timerLength * 1000).toLong(), 50){
|
||||
|
||||
override fun onTick(millisUntilFinished: Long) {
|
||||
count.value = millisUntilFinished.toFloat() / 1000
|
||||
}
|
||||
|
||||
override fun onFinish() {
|
||||
goToNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun newUiStateFromCurrentAccount(): StoriesUiState = StoriesUiState(
|
||||
profilePicture = currentAccount?.user?.avatar,
|
||||
age = currentAccount?.nodes?.getOrNull(0)?.created_at,
|
||||
username = currentAccount?.user?.username, //TODO check if not username_acct, think about falling back on other option?
|
||||
errorMessage = null,
|
||||
currentImage = 0,
|
||||
imageList = currentAccount?.nodes?.mapNotNull { it?.src } ?: emptyList(),
|
||||
durationList = currentAccount?.nodes?.mapNotNull { it?.duration } ?: emptyList()
|
||||
)
|
||||
|
||||
private fun goTo(index: Int){
|
||||
if((0 until uiState.value.imageList.size).contains(index)) {
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
currentImage = index,
|
||||
age = currentAccount?.nodes?.getOrNull(index)?.created_at,
|
||||
paused = false
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if(selfCarousel != null) return
|
||||
val currentUserId = currentAccount?.user?.id
|
||||
val currentAccountIndex = carousel?.nodes?.indexOfFirst { it?.user?.id == currentUserId } ?: return
|
||||
currentAccount = when (index) {
|
||||
uiState.value.imageList.size -> {
|
||||
// Go to next user
|
||||
if(currentAccountIndex + 1 >= carousel.nodes.size) return
|
||||
carousel.nodes.getOrNull(currentAccountIndex + 1)
|
||||
|
||||
}
|
||||
|
||||
-1 -> {
|
||||
// Go to previous user
|
||||
if(currentAccountIndex <= 0) return
|
||||
carousel.nodes.getOrNull(currentAccountIndex - 1)
|
||||
}
|
||||
else -> return // Do nothing, given index does not make sense
|
||||
}
|
||||
_uiState.update { newUiStateFromCurrentAccount() }
|
||||
}
|
||||
|
||||
timer?.cancel()
|
||||
startTimerForCurrent()
|
||||
}
|
||||
|
||||
fun goToNext() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
val story = currentAccount?.nodes?.getOrNull(uiState.value.currentImage)
|
||||
|
||||
if (story?.seen == true){
|
||||
//TODO update seen when marked successfully as seen?
|
||||
story.id?.let { api.storySeen(it) }
|
||||
}
|
||||
} catch (exception: Exception){
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(errorMessage = R.string.story_could_not_see)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
goTo(uiState.value.currentImage + 1)
|
||||
}
|
||||
|
||||
fun goToPrevious() = goTo(uiState.value.currentImage - 1)
|
||||
|
||||
private fun startTimerForCurrent(){
|
||||
uiState.value.let {
|
||||
it.durationList.getOrNull(it.currentImage)?.toLong()?.let { time ->
|
||||
setTimer(time.toFloat())
|
||||
timer?.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
if(_uiState.value.paused){
|
||||
timer?.start()
|
||||
} else {
|
||||
timer?.cancel()
|
||||
count.value?.let { setTimer(it) }
|
||||
}
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(paused = !currentUiState.paused)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendReply(text: Editable) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
currentStoryId()?.let { api.storyComment(it, text.toString()) }
|
||||
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(snackBar = R.string.sent_reply_story)
|
||||
}
|
||||
} catch (exception: Exception){
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(errorMessage = R.string.story_reply_error)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentStoryId(): String? = currentAccount?.nodes?.getOrNull(uiState.value.currentImage)?.id
|
||||
|
||||
fun replyChanged(text: String) {
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(reply = text)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissError() {
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(errorMessage = null)
|
||||
}
|
||||
}
|
||||
|
||||
fun shownSnackbar() {
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(snackBar = null)
|
||||
}
|
||||
}
|
||||
|
||||
fun currentProfileId(): String? = currentAccount?.user?.id
|
||||
|
||||
}
|
|
@ -0,0 +1,210 @@
|
|||
package org.pixeldroid.app.stories
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.graphics.RenderEffect
|
||||
import android.graphics.Shader
|
||||
import android.os.Build
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import kotlinx.coroutines.launch
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.StoryCarouselBinding
|
||||
import org.pixeldroid.app.databinding.StoryCarouselItemBinding
|
||||
import org.pixeldroid.app.databinding.StoryCarouselSelfBinding
|
||||
import org.pixeldroid.app.postCreation.camera.CameraActivity
|
||||
import org.pixeldroid.app.postCreation.camera.CameraFragment
|
||||
import org.pixeldroid.app.utils.api.objects.CarouselUserContainer
|
||||
import org.pixeldroid.app.utils.api.objects.Story
|
||||
import org.pixeldroid.app.utils.api.objects.StoryCarousel
|
||||
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
|
||||
|
||||
|
||||
/**
|
||||
* Adapter that has either 1 or 0 items, to show stories widget or not
|
||||
*/
|
||||
class StoriesAdapter(val lifecycleScope: LifecycleCoroutineScope, val apiHolder: PixelfedAPIHolder) : RecyclerView.Adapter<StoryCarouselViewHolder>() {
|
||||
var carousel: StoryCarousel? = null
|
||||
|
||||
/**
|
||||
* Whether to show stories or not.
|
||||
*
|
||||
* Changing this property will immediately notify the Adapter to change the item it's
|
||||
* presenting.
|
||||
*/
|
||||
var showStories: Boolean = false
|
||||
set(newValue) {
|
||||
val oldValue = field
|
||||
|
||||
if (oldValue && !newValue) {
|
||||
notifyItemRemoved(0)
|
||||
} else if (newValue && !oldValue) {
|
||||
notifyItemInserted(0)
|
||||
} else if (oldValue && newValue) {
|
||||
notifyItemChanged(0)
|
||||
}
|
||||
field = newValue
|
||||
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StoryCarouselViewHolder {
|
||||
return StoryCarouselViewHolder.create(parent, ::noStories)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: StoryCarouselViewHolder, position: Int) {
|
||||
holder.bind(carousel)
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int = 0
|
||||
|
||||
override fun getItemCount(): Int = if (showStories) 1 else 0
|
||||
|
||||
private fun noStories(){
|
||||
showStories = false
|
||||
}
|
||||
|
||||
private fun gotStories(newCarousel: StoryCarousel) {
|
||||
carousel = newCarousel
|
||||
showStories = true
|
||||
}
|
||||
|
||||
fun refreshStories(){
|
||||
lifecycleScope.launch {
|
||||
try{
|
||||
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
val carousel = api.carousel()
|
||||
|
||||
// If there are stories from someone else or our stories to show, show them
|
||||
if (carousel.nodes?.isEmpty() == false || carousel.self?.nodes?.isEmpty() == false) {
|
||||
// Pass carousel to adapter
|
||||
gotStories(carousel)
|
||||
} else {
|
||||
noStories()
|
||||
}
|
||||
} catch (exception: Exception){
|
||||
noStories()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class StoryCarouselViewHolder(val binding: StoryCarouselBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(carousel: StoryCarousel?) {
|
||||
val adapter = StoriesListAdapter()
|
||||
binding.storyCarousel.adapter = adapter
|
||||
|
||||
carousel?.let { adapter.initCarousel(it) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(parent: ViewGroup, noStories: () -> Unit): StoryCarouselViewHolder {
|
||||
val itemBinding = StoryCarouselBinding.inflate(
|
||||
LayoutInflater.from(parent.context), parent, false
|
||||
)
|
||||
return StoryCarouselViewHolder(itemBinding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class StoriesListAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private var storyCarousel: StoryCarousel? = null
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return if(viewType == R.layout.story_carousel_self){
|
||||
val v = StoryCarouselSelfBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
v.myStory.visibility =
|
||||
if (storyCarousel?.self?.nodes?.isEmpty() == false) View.VISIBLE
|
||||
else View.GONE
|
||||
|
||||
AddViewHolder(v)
|
||||
}
|
||||
else {
|
||||
val v = StoryCarouselItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
ViewHolder(v)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return if(position == 0) R.layout.story_carousel_self
|
||||
else R.layout.story_carousel_item
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
if(position > 0) {
|
||||
val carouselPosition = position - 1
|
||||
storyCarousel?.nodes?.get(carouselPosition)?.let { (holder as ViewHolder).bindItem(it) }
|
||||
holder.itemView.setOnClickListener {
|
||||
storyCarousel?.nodes?.get(carouselPosition)?.user?.id?.let { userId ->
|
||||
val intent = Intent(holder.itemView.context, StoriesActivity::class.java)
|
||||
intent.putExtra(StoriesActivity.STORY_CAROUSEL, storyCarousel)
|
||||
intent.putExtra(StoriesActivity.STORY_CAROUSEL_USER_ID, userId)
|
||||
holder.itemView.context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
storyCarousel?.self?.nodes?.let { (holder as? AddViewHolder)?.bindItem(it.filterNotNull()) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
// If the storyCarousel is not set, the carousel is not shown, so itemCount of 0
|
||||
return (storyCarousel?.nodes?.size?.plus(1)) ?: 0
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun initCarousel(carousel: StoryCarousel){
|
||||
storyCarousel = carousel
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
class AddViewHolder(private val itemBinding: StoryCarouselSelfBinding) : RecyclerView.ViewHolder(itemBinding.root) {
|
||||
fun bindItem(nodes: List<Story>) {
|
||||
itemBinding.addStory.setOnClickListener {
|
||||
val intent = Intent(itemView.context, CameraActivity::class.java)
|
||||
intent.putExtra(CameraFragment.CAMERA_ACTIVITY_STORY, true)
|
||||
itemView.context.startActivity(intent)
|
||||
}
|
||||
itemBinding.myStory.setOnClickListener {
|
||||
val intent = Intent(itemView.context, StoriesActivity::class.java)
|
||||
intent.putExtra(StoriesActivity.STORY_CAROUSEL_SELF, nodes.toTypedArray())
|
||||
itemView.context.startActivity(intent)
|
||||
}
|
||||
|
||||
// Only show image on new Android versions, because the transformations need it and the
|
||||
// text is not legible without the transformations
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
Glide.with(itemBinding.root).load(nodes.firstOrNull()?.src).into(itemBinding.carouselImageView)
|
||||
val value = 70 * 255 / 100
|
||||
val darkFilterRenderEffect = PorterDuffColorFilter(Color.argb(value, 0, 0, 0), PorterDuff.Mode.SRC_ATOP)
|
||||
val blurRenderEffect =
|
||||
RenderEffect.createBlurEffect(
|
||||
4f, 4f, Shader.TileMode.MIRROR
|
||||
)
|
||||
val combinedEffect = RenderEffect.createColorFilterEffect(darkFilterRenderEffect, blurRenderEffect)
|
||||
itemBinding.carouselImageView.setRenderEffect(combinedEffect)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(private val itemBinding: StoryCarouselItemBinding) :
|
||||
RecyclerView.ViewHolder(itemBinding.root) {
|
||||
fun bindItem(user: CarouselUserContainer) {
|
||||
Glide.with(itemBinding.root).load(user.nodes?.firstOrNull()?.src).into(itemBinding.carouselImageView)
|
||||
Glide.with(itemBinding.root).load(user.user?.avatar).circleCrop().into(itemBinding.storyAuthorProfilePicture)
|
||||
|
||||
itemBinding.username.text = user.user?.username ?: "" //TODO check which one to use here!
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
package org.pixeldroid.app.stories
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.Drawable
|
||||
|
||||
/**
|
||||
* Copied & adapted from AntennaPod's EchoProgress class because it looked great and is very simple
|
||||
* AntennaPod/ui/echo/src/main/java/de/danoeh/antennapod/ui/echo/EchoProgress.java
|
||||
*/
|
||||
class StoryProgress(private val numStories: Int) : Drawable() {
|
||||
private val paint: Paint = Paint().apply {
|
||||
flags = Paint.ANTI_ALIAS_FLAG
|
||||
style = Paint.Style.STROKE
|
||||
strokeJoin = Paint.Join.ROUND
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
color = -0x1
|
||||
}
|
||||
|
||||
var progress = 0f
|
||||
var currentStory: Int = 0
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
paint.strokeWidth = 0.5f * bounds.height()
|
||||
val y = 0.5f * bounds.height()
|
||||
val sectionWidth = 1.0f * bounds.width() / numStories
|
||||
val sectionPadding = 0.03f * sectionWidth
|
||||
// Iterate over stories
|
||||
for (i in 0 until numStories) {
|
||||
if (i < currentStory) {
|
||||
// If current drawing position is smaller than current story, the paint we will use
|
||||
// should be opaque: this story is already "seen"
|
||||
paint.alpha = 255
|
||||
} else {
|
||||
// Otherwise it should be somewhat transparent, denoting it is not yet seen
|
||||
paint.alpha = 100
|
||||
}
|
||||
// Draw an entire line with the paint, for now ignoring partial progress within the
|
||||
// current story
|
||||
canvas.drawLine(
|
||||
i * sectionWidth + sectionPadding,
|
||||
y,
|
||||
(i + 1) * sectionWidth - sectionPadding,
|
||||
y,
|
||||
paint
|
||||
)
|
||||
// If current position is equal to progress, we are drawing the current story. Thus we
|
||||
// should account for partial progress and paint the beginning of the line opaquely
|
||||
if (i == currentStory) {
|
||||
paint.alpha = 255
|
||||
canvas.drawLine(
|
||||
currentStory * sectionWidth + sectionPadding,
|
||||
y,
|
||||
currentStory * sectionWidth + sectionPadding + progress * (sectionWidth - 2 * sectionPadding),
|
||||
y,
|
||||
paint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun getOpacity(): Int {
|
||||
return PixelFormat.TRANSLUCENT
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) {}
|
||||
override fun setColorFilter(cf: ColorFilter?) {}
|
||||
}
|
||||
|
|
@ -1,63 +1,20 @@
|
|||
package org.pixeldroid.app.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.preference.PreferenceManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.pixeldroid.app.utils.db.AppDatabase
|
||||
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
|
||||
open class BaseActivity : AppCompatActivity() {
|
||||
@AndroidEntryPoint
|
||||
open class BaseActivity : org.pixeldroid.common.ThemedActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
@Inject
|
||||
lateinit var apiHolder: PixelfedAPIHolder
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
(this.application as PixelDroidApplication).getAppComponent().inject(this)
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
super.attachBaseContext(updateBaseContextLocale(base))
|
||||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
onBackPressed()
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun updateBaseContextLocale(context: Context): Context {
|
||||
val language = PreferenceManager.getDefaultSharedPreferences(context).getString("language", "default") ?: "default"
|
||||
if(language == "default"){
|
||||
return context
|
||||
}
|
||||
val locale = Locale.forLanguageTag(language)
|
||||
Locale.setDefault(locale)
|
||||
return if (Build.VERSION.SDK_INT > Build.VERSION_CODES.N) {
|
||||
updateResourcesLocale(context, locale)
|
||||
} else updateResourcesLocaleLegacy(context, locale)
|
||||
}
|
||||
|
||||
private fun updateResourcesLocale(context: Context, locale: Locale): Context =
|
||||
context.createConfigurationContext(
|
||||
Configuration(context.resources.configuration)
|
||||
.apply { setLocale(locale) }
|
||||
)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun updateResourcesLocaleLegacy(context: Context, locale: Locale): Context {
|
||||
val resources: Resources = context.resources
|
||||
val configuration: Configuration = resources.configuration
|
||||
configuration.locale = locale
|
||||
resources.updateConfiguration(configuration, resources.displayMetrics)
|
||||
return context
|
||||
}
|
||||
|
||||
}
|
|
@ -1,7 +1,10 @@
|
|||
package org.pixeldroid.app.utils
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.utils.db.AppDatabase
|
||||
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
|
||||
import javax.inject.Inject
|
||||
|
@ -9,6 +12,7 @@ import javax.inject.Inject
|
|||
/**
|
||||
* Base Fragment, for dependency injection and other things common to a lot of the fragments
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
open class BaseFragment: Fragment() {
|
||||
|
||||
@Inject
|
||||
|
@ -17,9 +21,18 @@ open class BaseFragment: Fragment() {
|
|||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
(requireActivity().application as PixelDroidApplication).getAppComponent().inject(this)
|
||||
}
|
||||
internal val requestPermissionDownloadPic =
|
||||
registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted: Boolean ->
|
||||
if (!isGranted) {
|
||||
context?.let {
|
||||
MaterialAlertDialogBuilder(it)
|
||||
.setMessage(R.string.write_permission_download_pic)
|
||||
.setNegativeButton(android.R.string.ok) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
package org.pixeldroid.app.utils
|
||||
|
||||
import android.os.Bundle
|
||||
|
||||
open class BaseThemedWithBarActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Set theme when we chose one
|
||||
themeActionBar()?.let { setTheme(it) }
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
package org.pixeldroid.app.utils
|
||||
|
||||
import android.os.Bundle
|
||||
|
||||
open class BaseThemedWithoutBarActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Set theme when we chose one
|
||||
themeNoActionBar()?.let { setTheme(it) }
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
}
|
|
@ -24,6 +24,7 @@ fun setProfileImageFromURL(view : View, url : String?, image : ImageView) {
|
|||
* @param image, the imageView into which we will load the image
|
||||
*/
|
||||
fun setSquareImageFromURL(view : View, url : String?, image : ImageView, blurhash: String? = null) {
|
||||
//TODO performance: placeholder here takes a lot of time to compute and this is not async!
|
||||
Glide.with(view).load(url).placeholder(
|
||||
blurhash?.let { BlurHashDecoder.blurHashBitmap(view.resources, it, 32, 32) }
|
||||
).apply(RequestOptions().centerCrop()).into(image)
|
||||
|
|
|
@ -3,14 +3,12 @@ package org.pixeldroid.app.utils
|
|||
import android.app.Application
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import org.ligi.tracedroid.TraceDroid
|
||||
import org.pixeldroid.app.utils.di.*
|
||||
|
||||
|
||||
@HiltAndroidApp
|
||||
class PixelDroidApplication: Application() {
|
||||
|
||||
private lateinit var mApplicationComponent: ApplicationComponent
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
|
@ -19,18 +17,7 @@ class PixelDroidApplication: Application() {
|
|||
val sharedPreferences =
|
||||
PreferenceManager.getDefaultSharedPreferences(this)
|
||||
setThemeFromPreferences(sharedPreferences, resources)
|
||||
mApplicationComponent = DaggerApplicationComponent
|
||||
.builder()
|
||||
.applicationModule(ApplicationModule(this))
|
||||
.databaseModule(DatabaseModule(applicationContext))
|
||||
.aPIModule(APIModule())
|
||||
.build()
|
||||
mApplicationComponent.inject(this)
|
||||
|
||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||
}
|
||||
|
||||
fun getAppComponent(): ApplicationComponent {
|
||||
return mApplicationComponent
|
||||
}
|
||||
}
|
|
@ -1,31 +1,27 @@
|
|||
package org.pixeldroid.app.utils
|
||||
|
||||
import android.content.*
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.ImageDecoder
|
||||
import android.graphics.Matrix
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.WindowManager
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.gson.JsonDeserializer
|
||||
import com.google.gson.JsonElement
|
||||
|
@ -35,7 +31,7 @@ import okhttp3.HttpUrl
|
|||
import org.pixeldroid.app.R
|
||||
import java.time.Instant
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.*
|
||||
import java.util.Locale
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
|
@ -96,55 +92,7 @@ fun normalizeDomain(domain: String): String {
|
|||
.trim(Char::isWhitespace)
|
||||
}
|
||||
|
||||
fun Context.ffmpegCompliantUri(inputUri: Uri?): String =
|
||||
if (inputUri?.scheme == "content")
|
||||
FFmpegKitConfig.getSafParameterForRead(this, inputUri)
|
||||
else inputUri.toString()
|
||||
|
||||
|
||||
fun bitmapFromUri(contentResolver: ContentResolver, uri: Uri?): Bitmap =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
ImageDecoder
|
||||
.decodeBitmap(
|
||||
ImageDecoder.createSource(contentResolver, uri!!)
|
||||
)
|
||||
{ decoder, _, _ -> decoder.isMutableRequired = true }
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val bitmap = MediaStore.Images.Media.getBitmap(contentResolver, uri)
|
||||
modifyOrientation(bitmap!!, contentResolver, uri!!)
|
||||
}
|
||||
|
||||
fun modifyOrientation(
|
||||
bitmap: Bitmap,
|
||||
contentResolver: ContentResolver,
|
||||
uri: Uri
|
||||
): Bitmap {
|
||||
val inputStream = contentResolver.openInputStream(uri)!!
|
||||
val ei = ExifInterface(inputStream)
|
||||
return when (ei.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)) {
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> bitmap.rotate(90f)
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> bitmap.rotate(180f)
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> bitmap.rotate(270f)
|
||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> bitmap.flip(horizontal = true, vertical = false)
|
||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> bitmap.flip(horizontal = false, vertical = true)
|
||||
else -> bitmap
|
||||
}
|
||||
}
|
||||
|
||||
fun Bitmap.rotate(degrees: Float): Bitmap {
|
||||
val matrix = Matrix()
|
||||
matrix.postRotate(degrees)
|
||||
return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true)
|
||||
}
|
||||
|
||||
fun Bitmap.flip(horizontal: Boolean, vertical: Boolean): Bitmap {
|
||||
val matrix = Matrix()
|
||||
matrix.preScale(if (horizontal) -1f else 1f, if (vertical) -1f else 1f)
|
||||
return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true)
|
||||
}
|
||||
|
||||
fun BaseActivity.openUrl(url: String): Boolean {
|
||||
fun Context.openUrl(url: String): Boolean {
|
||||
|
||||
val intent = CustomTabsIntent.Builder().build()
|
||||
|
||||
|
@ -210,36 +158,6 @@ fun setThemeFromPreferences(preferences: SharedPreferences, resources: Resources
|
|||
}
|
||||
}
|
||||
|
||||
@StyleRes
|
||||
fun Context.themeNoActionBar(): Int? {
|
||||
return when(PreferenceManager.getDefaultSharedPreferences(this).getInt("themeColor", 0)) {
|
||||
// No theme was chosen: the user wants to use the system dynamic color (from wallpaper for example)
|
||||
-1 -> null
|
||||
1 -> R.style.AppTheme2_NoActionBar
|
||||
2 -> R.style.AppTheme3_NoActionBar
|
||||
3 -> R.style.AppTheme4_NoActionBar
|
||||
else -> R.style.AppTheme5_NoActionBar
|
||||
}
|
||||
}
|
||||
|
||||
@StyleRes
|
||||
fun Context.themeActionBar(): Int? {
|
||||
return when(PreferenceManager.getDefaultSharedPreferences(this).getInt("themeColor", 0)) {
|
||||
// No theme was chosen: the user wants to use the system dynamic color (from wallpaper for example)
|
||||
-1 -> null
|
||||
1 -> R.style.AppTheme2
|
||||
2 -> R.style.AppTheme3
|
||||
3 -> R.style.AppTheme4
|
||||
else -> R.style.AppTheme5
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps a Float from this range to target range */
|
||||
fun ClosedRange<Float>.convert(number: Float, target: ClosedRange<Float>): Float {
|
||||
val ratio = number / (endInclusive - start)
|
||||
return (ratio * (target.endInclusive - target.start))
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun Context.getColorFromAttr(@AttrRes attrColor: Int): Int = MaterialColors.getColor(this, attrColor, Color.BLACK)
|
||||
|
||||
|
|
|
@ -7,6 +7,9 @@ import okhttp3.Interceptor
|
|||
import org.pixeldroid.app.utils.api.objects.*
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import org.pixeldroid.app.searchDiscover.TrendingActivity
|
||||
import org.pixeldroid.app.utils.api.objects.Collection
|
||||
import org.pixeldroid.app.utils.api.objects.Tag
|
||||
import org.pixeldroid.app.utils.db.AppDatabase
|
||||
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
|
||||
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
|
||||
|
@ -20,7 +23,7 @@ import retrofit2.converter.gson.GsonConverterFactory
|
|||
import retrofit2.http.*
|
||||
import retrofit2.http.Field
|
||||
import java.time.Instant
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
||||
/*
|
||||
|
@ -49,7 +52,9 @@ interface PixelfedAPI {
|
|||
.client(
|
||||
OkHttpClient().newBuilder().addNetworkInterceptor(headerInterceptor)
|
||||
// Only do secure-ish TLS connections (no HTTP or very old SSL/TLS)
|
||||
.connectionSpecs(listOf(ConnectionSpec.MODERN_TLS)).build()
|
||||
.connectionSpecs(listOf(ConnectionSpec.MODERN_TLS))
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.build()
|
||||
)
|
||||
.build().create(PixelfedAPI::class.java)
|
||||
}
|
||||
|
@ -72,6 +77,7 @@ interface PixelfedAPI {
|
|||
OkHttpClient().newBuilder().addNetworkInterceptor(headerInterceptor)
|
||||
// Only do secure-ish TLS connections (no HTTP or very old SSL/TLS)
|
||||
.connectionSpecs(listOf(ConnectionSpec.MODERN_TLS))
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.authenticator(TokenAuthenticator(user, db, pixelfedAPIHolder))
|
||||
.addInterceptor {
|
||||
it.request().newBuilder().run {
|
||||
|
@ -152,18 +158,19 @@ interface PixelfedAPI {
|
|||
@FormUrlEncoded
|
||||
@POST("/api/v1/statuses")
|
||||
suspend fun postStatus(
|
||||
@Field("status") statusText : String,
|
||||
@Field("in_reply_to_id") in_reply_to_id : String? = null,
|
||||
@Field("media_ids[]") media_ids : List<String> = emptyList(),
|
||||
@Field("poll[options][]") poll_options : List<String>? = null,
|
||||
@Field("poll[expires_in]") poll_expires : List<String>? = null,
|
||||
@Field("poll[multiple]") poll_multiple : List<String>? = null,
|
||||
@Field("poll[hide_totals]") poll_hideTotals : List<String>? = null,
|
||||
@Field("sensitive") sensitive : Boolean? = null,
|
||||
@Field("spoiler_text") spoiler_text : String? = null,
|
||||
@Field("visibility") visibility : String = "public",
|
||||
@Field("scheduled_at") scheduled_at : String? = null,
|
||||
@Field("language") language : String? = null
|
||||
@Field("status") statusText: String,
|
||||
@Field("in_reply_to_id") in_reply_to_id: String? = null,
|
||||
@Field("media_ids[]") media_ids: List<String> = emptyList(),
|
||||
@Field("poll[options][]") poll_options: List<String>? = null,
|
||||
@Field("poll[expires_in]") poll_expires: List<String>? = null,
|
||||
@Field("poll[multiple]") poll_multiple: List<String>? = null,
|
||||
@Field("poll[hide_totals]") poll_hideTotals: List<String>? = null,
|
||||
//FIXME this should be able to take a boolean or at least "true"/"false" but only "0"/"1" works
|
||||
@Field("sensitive") sensitive: Int? = null,
|
||||
@Field("spoiler_text") spoiler_text: String? = null,
|
||||
@Field("visibility") visibility: String = "public",
|
||||
@Field("scheduled_at") scheduled_at: String? = null,
|
||||
@Field("language") language: String? = null
|
||||
) : Status
|
||||
|
||||
@DELETE("/api/v1/statuses/{id}")
|
||||
|
@ -201,6 +208,70 @@ interface PixelfedAPI {
|
|||
@Path("id") statusId: String
|
||||
) : Status
|
||||
|
||||
@GET("/api/v1.1/collections/accounts/{id}")
|
||||
suspend fun accountCollections(
|
||||
@Path("id") account_id: String? = null
|
||||
): List<Collection>
|
||||
|
||||
@GET("/api/v1.1/collections/items/{id}")
|
||||
suspend fun collectionItems(
|
||||
@Path("id") id: String,
|
||||
@Query("page") page: String? = null
|
||||
): List<Status>
|
||||
|
||||
@DELETE("/api/v1.1/collections/delete/{id}")
|
||||
suspend fun deleteCollection(
|
||||
@Path("id") id: String,
|
||||
)
|
||||
|
||||
@POST("/api/v1.1/collections/add")
|
||||
suspend fun addToCollection(
|
||||
@Query("collection_id") collection_id: String,
|
||||
@Query("post_id") post_id: String,
|
||||
): Status
|
||||
|
||||
@POST("/api/v1.1/collections/remove")
|
||||
suspend fun removeFromCollection(
|
||||
@Query("collection_id") collection_id: String,
|
||||
@Query("post_id") post_id: String,
|
||||
)
|
||||
|
||||
@GET("/api/pixelfed/v1/stories/self-carousel")
|
||||
suspend fun carousel(): StoryCarousel
|
||||
|
||||
@POST("/api/v1.1/stories/seen")
|
||||
suspend fun storySeen(
|
||||
@Query("id") id: String
|
||||
)
|
||||
|
||||
@POST("/api/v1.1/stories/comment")
|
||||
suspend fun storyComment(
|
||||
@Query("sid") sid: String,
|
||||
@Query("caption") caption: String
|
||||
)
|
||||
|
||||
@Multipart
|
||||
@POST("/api/v1.1/stories/add")
|
||||
fun storyUpload(
|
||||
@Part file: MultipartBody.Part,
|
||||
// The API takes this value but then overwrites it in /api/v1.1/stories/publish, so ignore this
|
||||
@Part duration: MultipartBody.Part? = null,
|
||||
): Observable<Attachment>
|
||||
|
||||
@POST("/api/v1.1/stories/publish")
|
||||
suspend fun storyPublish(
|
||||
@Query("media_id") media_id: String,
|
||||
//From 0 to 30, duration in seconds of the story
|
||||
@Query("duration") duration: Int = 10,
|
||||
//FIXME this should be able to take a boolean or at least "true"/"false" but only "0"/"1" works. Same issue as sensitive boolean in postStatus
|
||||
@Query("can_reply") can_reply: String,
|
||||
@Query("can_react") can_react: String,
|
||||
)
|
||||
|
||||
@POST("/api/v1.1/stories/self-expire/{id}")
|
||||
suspend fun deleteCarousel(
|
||||
@Path("id") storyId: String
|
||||
)
|
||||
|
||||
//Used in our case to retrieve comments for a given status
|
||||
@GET("/api/v1/statuses/{id}/context")
|
||||
|
@ -267,6 +338,33 @@ interface PixelfedAPI {
|
|||
@Header("Authorization") authorization: String? = null
|
||||
): Account
|
||||
|
||||
@PATCH("/api/v1/accounts/update_credentials")
|
||||
suspend fun updateCredentials(
|
||||
@Query(value = "display_name") displayName: String?,
|
||||
@Query(value = "note") note: String?,
|
||||
@Query(value = "locked") locked: Boolean?,
|
||||
): Account
|
||||
|
||||
/**
|
||||
* Pixelfed uses PHP, multipart uploads don't work through PATCH so we use POST as suggested
|
||||
* here: https://github.com/pixelfed/pixelfed/issues/4250
|
||||
* However, changing to POST breaks the upload on Mastodon.
|
||||
*
|
||||
* To have this work on Pixelfed and Mastodon without special logic to distinguish the two,
|
||||
* we'll have to wait for PHP 8.4 and https://wiki.php.net/rfc/rfc1867-non-post
|
||||
* which should come out end of 2024
|
||||
*/
|
||||
@Multipart
|
||||
@POST("/api/v1/accounts/update_credentials")
|
||||
fun updateProfilePicture(
|
||||
@Part avatar: MultipartBody.Part?
|
||||
): Observable<Account>
|
||||
|
||||
@Multipart
|
||||
@PATCH("/api/v1/accounts/update_credentials")
|
||||
fun updateProfilePictureMastodon(
|
||||
@Part avatar: MultipartBody.Part?
|
||||
): Observable<Account>
|
||||
|
||||
@GET("/api/v1/accounts/{id}/statuses")
|
||||
suspend fun accountPosts(
|
||||
|
@ -320,6 +418,17 @@ interface PixelfedAPI {
|
|||
@GET("/api/v1/discover/posts")
|
||||
suspend fun discover() : DiscoverPosts
|
||||
|
||||
@GET("/api/v1.1/discover/accounts/popular")
|
||||
suspend fun popularAccounts() : List<Account>
|
||||
|
||||
@GET("/api/v1.1/discover/posts/trending")
|
||||
suspend fun trendingPosts(
|
||||
@Query("range") range: TrendingActivity.Companion.Range
|
||||
) : List<Status>
|
||||
|
||||
@GET("/api/v1.1/discover/posts/hashtags")
|
||||
suspend fun trendingHashtags() : List<Tag>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("/api/v1/reports")
|
||||
@JvmSuppressWildcards
|
||||
|
|
|
@ -57,11 +57,13 @@ data class Account(
|
|||
suspend fun openAccountFromId(id: String, api : PixelfedAPI, context: Context) {
|
||||
val account = try {
|
||||
api.getAccount(id)
|
||||
} catch (exception: IOException) {
|
||||
Log.e("GET ACCOUNT ERROR", exception.toString())
|
||||
return
|
||||
} catch (exception: HttpException) {
|
||||
Log.e("ERROR CODE", exception.code().toString())
|
||||
} catch (exception: Exception) {
|
||||
val toLog = if (exception is HttpException) {
|
||||
exception.code().toString()
|
||||
} else {
|
||||
exception.toString()
|
||||
}
|
||||
Log.e("GET ACCOUNT ERROR", toLog)
|
||||
return
|
||||
}
|
||||
//Open the account page in a separate activity
|
||||
|
|
|
@ -18,6 +18,12 @@ data class Attachment(
|
|||
|
||||
//Deprecated attributes
|
||||
val text_url: String? = null, //URL
|
||||
|
||||
//Pixelfed's Story upload response... TODO make the server return a regular Attachment?
|
||||
val msg: String? = null,
|
||||
val media_id: String? = null,
|
||||
val media_url: String? = null,
|
||||
val media_type: String? = null,
|
||||
) : Serializable {
|
||||
enum class AttachmentType: Serializable {
|
||||
unknown, image, gifv, video, audio
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
package org.pixeldroid.app.utils.api.objects
|
||||
|
||||
import java.io.Serializable
|
||||
import java.time.Instant
|
||||
|
||||
data class Collection(
|
||||
override val id: String, // Id of the profile
|
||||
val pid: String, // Account id
|
||||
val visibility: Visibility, // Public or private, or draft for your own collections
|
||||
val title: String,
|
||||
val description: String,
|
||||
val thumb: String, // URL to the thumbnail of this collection
|
||||
val updated_at: Instant,
|
||||
val published_at: Instant,
|
||||
val avatar: String, // URL to the avatar of the author of this collection
|
||||
val username: String, // Username of author
|
||||
val post_count: Int, //Number of posts in collection
|
||||
): FeedContent, Serializable {
|
||||
enum class Visibility: Serializable {
|
||||
public, private, draft
|
||||
}
|
||||
}
|
|
@ -1,5 +1,12 @@
|
|||
package org.pixeldroid.app.utils.api.objects
|
||||
|
||||
import java.io.Serializable
|
||||
import java.time.Instant
|
||||
|
||||
class Field: Serializable
|
||||
data class Field(
|
||||
//Required attributes
|
||||
val name: String?,
|
||||
val value: String?,
|
||||
//Optional attributes
|
||||
val verified_at: Instant?
|
||||
): Serializable
|
||||
|
|
|
@ -2,4 +2,16 @@ package org.pixeldroid.app.utils.api.objects
|
|||
|
||||
import java.io.Serializable
|
||||
|
||||
class Source: Serializable
|
||||
data class Source(
|
||||
val note: String?,
|
||||
val fields: List<Field>?,
|
||||
//Nullable attributes
|
||||
val privacy: Privacy?,
|
||||
val sensitive: Boolean?,
|
||||
val language: String?, //ISO 639-1 language two-letter code
|
||||
val follow_requests_count: Int?,
|
||||
): Serializable {
|
||||
enum class Privacy: Serializable {
|
||||
public, unlisted, private, direct
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
package org.pixeldroid.app.utils.api.objects
|
||||
|
||||
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
|
@ -11,6 +13,7 @@ import androidx.core.net.toUri
|
|||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.posts.getDomain
|
||||
import org.pixeldroid.app.utils.getMimeType
|
||||
import java.io.File
|
||||
import java.io.Serializable
|
||||
import java.time.Instant
|
||||
|
@ -148,11 +151,13 @@ open class Status(
|
|||
)
|
||||
val file = path.toUri()
|
||||
|
||||
|
||||
|
||||
val shareIntent: Intent = Intent.createChooser(Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_STREAM, file)
|
||||
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
type = "image/$ext"
|
||||
type = file.getMimeType(context.contentResolver)
|
||||
}, null)
|
||||
|
||||
context.startActivity(shareIntent)
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
package org.pixeldroid.app.utils.api.objects
|
||||
|
||||
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
|
||||
import java.io.Serializable
|
||||
import java.time.Instant
|
||||
|
||||
data class StoryCarousel(
|
||||
val self: CarouselUserContainer?,
|
||||
val nodes: List<CarouselUserContainer?>?
|
||||
): Serializable
|
||||
|
||||
data class CarouselUser(
|
||||
val id: String?,
|
||||
val username: String?,
|
||||
val username_acct: String?,
|
||||
val avatar: String?, // URL to account avatar
|
||||
val local: Boolean?, // Is this story from the local instance?
|
||||
val is_author: Boolean?, // Is this me? (seems redundant with id)
|
||||
): Serializable
|
||||
|
||||
/**
|
||||
* Container with a description of the [user] and a list of stories ([nodes])
|
||||
*/
|
||||
data class CarouselUserContainer(
|
||||
val user: CarouselUser?,
|
||||
val nodes: List<Story?>?,
|
||||
): Serializable {
|
||||
constructor(user: UserDatabaseEntity, nodes: List<Story?>?) : this(
|
||||
CarouselUser(user.user_id, user.username, null, user.avatar_static,
|
||||
local = true,
|
||||
is_author = true
|
||||
), nodes)
|
||||
}
|
||||
|
||||
data class Story(
|
||||
val id: String?,
|
||||
val pid: String?, // id of author
|
||||
val type: String?, //TODO make enum of this? examples: "photo", ???
|
||||
val src: String?, // URL to photo of story
|
||||
val duration: Int?, //Time in seconds that the Story should be shown
|
||||
val seen: Boolean?, //Indication of whether this story has been seen. Set to true using carouselSeen
|
||||
val created_at: Instant?, //ISO 8601 Datetime
|
||||
): Serializable
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue