Compare commits

...

318 Commits

Author SHA1 Message Date
Matthieu 187c29a751 Merge branch 'translations' into 'master'
Translations update from Weblate

See merge request pixeldroid/PixelDroid!590
2024-04-15 03:09:08 +00:00
Balaraz f9a07a5dd0 Translated using Weblate (Ukrainian)
Currently translated at 73.0% (19 of 26 strings)

Translated using Weblate (Ukrainian)

Currently translated at 97.2% (248 of 255 strings)

Translated using Weblate (Ukrainian)

Currently translated at 97.2% (248 of 255 strings)

Co-authored-by: Balaraz <balaraz@tuta.io>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/uk/
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/fastlane/uk/
Translation: PixelDroid/Fastlane
Translation: PixelDroid/pixeldroid
2024-04-08 06:15:38 +00:00
Alexandre NICOLADIE fb9296187a Translated using Weblate (French)
Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (French)

Currently translated at 100.0% (255 of 255 strings)

Translated using Weblate (French)

Currently translated at 26.9% (7 of 26 strings)

Translated using Weblate (French)

Currently translated at 80.3% (205 of 255 strings)

Co-authored-by: Alexandre NICOLADIE <github@nicoladie.fr>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/fr/
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/fastlane/fr/
Translation: PixelDroid/Fastlane
Translation: PixelDroid/pixeldroid
2024-04-08 06:15:37 +00:00
Matthieu 04324577ea Merge branch 'dependencies_upgrade' into 'master'
Dependencies upgrade

See merge request pixeldroid/PixelDroid!589
2024-03-29 08:33:37 +00:00
Matthieu 73f08e5a5f Update dependencies 2024-03-29 09:03:48 +01:00
Matthieu a7feab380b Merge remote-tracking branch 'origin/master' 2024-03-23 15:39:07 +01:00
Matthieu 1a4e023091 Merge branch 'translations' into 'master'
Translations update from Weblate

See merge request pixeldroid/PixelDroid!588
2024-03-22 14:35:59 +00:00
Regu_Miabyss 2fb4c91ffd Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (255 of 255 strings)

Co-authored-by: Regu_Miabyss <Regu_Miabyss@outlook.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/zh_Hant/
Translation: PixelDroid/pixeldroid
2024-03-22 14:34:56 +00:00
Matthieu 720c099f0a Merge branch 'changelog_update' into 'master'
Adapt to multiplied version numbers

See merge request pixeldroid/PixelDroid!587
2024-03-16 09:11:38 +00:00
Matthieu 869479d53f Adapt to multiplied version numbers 2024-03-16 09:11:31 +00:00
Matthieu 018f893388 Adapt to multiplied version numbers 2024-03-16 10:10:39 +01:00
Matthieu f27ae611be Merge branch 'release33_3' into 'master'
Fix build typo

See merge request pixeldroid/PixelDroid!586
2024-03-16 07:53:28 +00:00
Matthieu 234be72f59 Fix build typo 2024-03-16 08:52:58 +01:00
Matthieu 7eaac2e903 Merge branch 'release33_2' into 'master'
Release33 fixup

See merge request pixeldroid/PixelDroid!585
2024-03-16 07:41:21 +00:00
Matthieu a9849c13e6 Switch submodule to http pull 2024-03-16 08:37:43 +01:00
Matthieu d1562f18e9 Fix order of abiCodes 2024-03-16 08:34:55 +01:00
Matthieu 4a1248bcab Merge branch 'translations' into 'master'
Translations update from Weblate

See merge request pixeldroid/PixelDroid!583
2024-03-15 18:15:14 +00:00
Regu_Miabyss e9122e6d72 Translated using Weblate (Chinese (Traditional))
Currently translated at 29.0% (74 of 255 strings)

Co-authored-by: Regu_Miabyss <Regu_Miabyss@outlook.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/zh_Hant/
Translation: PixelDroid/pixeldroid
2024-03-15 18:07:42 +00:00
Matthieu 76eac62d73 Merge branch 'seperate-apks2' into 'master'
Seperate version codes per architecture

See merge request pixeldroid/PixelDroid!584
2024-03-15 18:07:39 +00:00
Matthieu dd27555d83 Release 33 2024-03-15 18:10:10 +01:00
Matthieu c0feb8a37d Seperate version codes per architecture 2024-03-15 18:01:04 +01:00
Matthieu 7acd4cface Merge branch 'view_models' into 'master'
Use ViewModel in AlbumActivity

See merge request pixeldroid/PixelDroid!558
2024-03-10 10:22:07 +00:00
Matthieu 10e93c90b7 Merge branch 'master' into view_models 2024-03-08 11:06:33 +01:00
Matthieu 2311627473 Merge branch 'translations' into 'master'
Translations update from Weblate

See merge request pixeldroid/PixelDroid!582
2024-03-08 09:04:39 +00:00
Regu_Miabyss 6c7ab2333e Added translation using Weblate (Chinese (Traditional))
Co-authored-by: Regu_Miabyss <Regu_Miabyss@outlook.com>
2024-03-08 09:04:03 +00:00
Matthieu 54711d4e81 Hide dot indicator in fullscreen album 2024-03-07 07:56:30 +01:00
Matthieu 908d1a54c9 Merge branch 'master' into view_models 2024-03-05 10:59:52 +01:00
Matthieu 2b91543137 Merge branch 'seperate-apks' into 'master'
Split apks into ABIs to make them smaller

See merge request pixeldroid/PixelDroid!581
2024-03-01 15:41:30 +00:00
Matthieu 0688dd4d02 Release 32 2024-03-01 16:41:02 +01:00
Matthieu 319da7c11c Split apks into ABIs to make them smaller 2024-03-01 16:36:08 +01:00
Matthieu 7b327fc0d6 Merge branch 'readme-matrix' into 'master'
Update README.md

See merge request pixeldroid/PixelDroid!580
2024-02-27 13:48:08 +00:00
Matthieu 64ab2c2ac5 Update README.md 2024-02-27 13:23:07 +00:00
Matthieu 37b83f5ae2 Merge branch 'fix_uri_mess' into 'master'
Fix crashes due to ClassCastException

See merge request pixeldroid/PixelDroid!579
2024-02-25 11:03:55 +00:00
Matthieu 1516452ab5 Release 31 2024-02-25 12:03:05 +01:00
Matthieu cb50db7730 Fix crashes due to ClassCastException 2024-02-25 11:54:09 +01:00
Matthieu afe6f71152 Merge branch 'release30' into 'master'
Release 30

See merge request pixeldroid/PixelDroid!578
2024-02-14 21:23:13 +00:00
Matthieu d66c365934 Update dependencies 2024-02-14 22:00:48 +01:00
Matthieu 7815ecba08 Release 30 2024-02-14 21:59:49 +01:00
Matthieu b4533014b3 Update media_editor (bugfix) 2024-02-14 21:57:42 +01:00
Matthieu 905c1c2d66 Merge branch 'release29' into 'master'
New release

See merge request pixeldroid/PixelDroid!577
2024-02-10 17:52:11 +00:00
Matthieu 46ee92a19f New release 2024-02-10 18:48:40 +01:00
Matthieu ed7ff877fb Merge branch 'translations' into 'master'
Translations update from Weblate

See merge request pixeldroid/PixelDroid!576
2024-02-10 17:44:05 +00:00
Francesco Marinucci 5c3b231e16 Translated using Weblate (Italian)
Currently translated at 40.9% (9 of 22 strings)

Co-authored-by: Francesco Marinucci <francesco.marinucci@linux.it>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/fastlane/it/
Translation: PixelDroid/Fastlane
2024-02-10 17:43:09 +00:00
Matthieu 80e021e1a2 Merge branch 'hilt' into 'master'
Migration from Dagger to Hilt

See merge request pixeldroid/PixelDroid!575
2024-02-10 17:43:07 +00:00
Matthieu 0aa3d86c11 Migration from Dagger to Hilt 2024-02-10 17:43:07 +00:00
Matthieu 05cb615f15 Merge branch 'release28' into 'master'
Release 28

See merge request pixeldroid/PixelDroid!574
2024-01-31 15:45:29 +00:00
Matthieu 9313f321cd Release 28 2024-01-31 16:45:04 +01:00
Matthieu 175438115d Merge branch 'image_editing_improvements' into 'master'
Update dependency on media_editor

See merge request pixeldroid/PixelDroid!566
2024-01-31 11:41:20 +00:00
Matthieu f2600b85e2 Update dependency on media_editor 2024-01-31 11:41:20 +00:00
Matthieu 711a5b310f Merge branch 'translations' into 'master'
Translations update from Weblate

See merge request pixeldroid/PixelDroid!573
2024-01-31 10:00:49 +00:00
Francesco Marinucci 0192190c6d Translated using Weblate (Italian)
Currently translated at 38.0% (8 of 21 strings)

Translated using Weblate (Italian)

Currently translated at 99.6% (254 of 255 strings)

Co-authored-by: Francesco Marinucci <francesco.marinucci@linux.it>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/it/
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/fastlane/it/
Translation: PixelDroid/Fastlane
Translation: PixelDroid/pixeldroid
2024-01-31 02:10:13 +00:00
Matthieu bb935e73ad Merge branch 'improve_profile_layout' into 'master'
Improve/change profile and post layouts

See merge request pixeldroid/PixelDroid!572
2024-01-30 13:06:44 +00:00
Matthieu ddf1b273de Improve/change profile and post layouts 2024-01-30 13:44:07 +01:00
Matthieu 14dee5463e Merge branch 'fix_crash_cutout' into 'master'
Update dependency to fix crash

See merge request pixeldroid/PixelDroid!571
2024-01-27 14:25:35 +00:00
Matthieu 416d36b1a8 Update dependency to fix crash 2024-01-27 14:30:53 +01:00
Matthieu 370aeda4a6 Merge branch 'edit_profile_picture' into 'master'
Fix profile pic edit

Closes #377

See merge request pixeldroid/PixelDroid!569
2024-01-26 12:22:51 +00:00
Matthieu eb65e24099 Circlecrop directly without RequestOptions 2024-01-26 13:16:33 +01:00
Fred 1ea4371b3e Crop newly changed profile pic to circle 2024-01-24 23:22:58 +01:00
Matthieu 2e399884e9 Fix #377 2024-01-24 22:20:08 +01:00
Matthieu 1dcf605976 Improve consistency of ViewModel and UI 2024-01-23 17:27:45 +01:00
Fred 06478cf8a7 Increment version number 2024-01-22 21:50:04 +00:00
Fred 580f7ca911 Fix profile pic edit
See https://github.com/pixelfed/pixelfed/issues/4250
2024-01-22 21:50:04 +00:00
Matthieu 23fbebfe44 Merge branch 'upgrade_pixel_common' into 'master'
Upgrade common library to latest commit

See merge request pixeldroid/PixelDroid!568
2024-01-22 20:25:43 +00:00
Fred 6602d912a9 Upgrade common library to latest commit
- Solves issue with displaying AboutActivity
2024-01-22 21:03:25 +01:00
Matthieu 1f41268d55 Merge branch 'translations' into 'master'
Translations update from Weblate

See merge request pixeldroid/PixelDroid!567
2024-01-20 08:25:50 +00:00
Francesco Marinucci 15efdcabad Translated using Weblate (Italian)
Currently translated at 95.6% (244 of 255 strings)

Co-authored-by: Francesco Marinucci <francesco.marinucci@linux.it>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/it/
Translation: PixelDroid/pixeldroid
2024-01-20 00:32:36 +00:00
Matthieu 97069a76db Merge branch 'translations' into 'master'
Translations update from Weblate

See merge request pixeldroid/PixelDroid!564
2024-01-19 09:02:11 +00:00
Francesco Marinucci 834b3f86bd Translated using Weblate (Italian)
Currently translated at 81.8% (208 of 254 strings)

Co-authored-by: Francesco Marinucci <francesco.marinucci@linux.it>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/it/
Translation: PixelDroid/pixeldroid
2024-01-19 08:25:31 +00:00
ButterflyOfFire adc4ef0199 Translated using Weblate (French)
Currently translated at 72.4% (184 of 254 strings)

Co-authored-by: ButterflyOfFire <butterflyoffire+pixeldroid@protonmail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/fr/
Translation: PixelDroid/pixeldroid
2024-01-19 08:25:31 +00:00
Matthieu dafe827a5c Merge branch 'fix_comment_string' into 'master'
Fix comment string being both verb and noun

See merge request pixeldroid/PixelDroid!565
2024-01-19 08:25:29 +00:00
Matthieu 0f8602b3f1 Fix comment string being both verb and noun 2024-01-19 09:04:28 +01:00
Matthieu 6edb394ccb Merge branch 'translations' into 'master'
Translations update from Weblate

See merge request pixeldroid/PixelDroid!563
2024-01-12 09:11:58 +00:00
Weblate 13fa45cdb0 Added translation using Weblate (Interlingua)
Co-authored-by: Weblate <noreply@weblate.org>
2024-01-12 03:37:15 +00:00
Matthieu e108d08916 Merge branch 'fix_share_and_save' into 'master'
Fix sharing and saving images & album crash on older Android versions

Closes #369, #371, and #372

See merge request pixeldroid/PixelDroid!562
2024-01-03 15:44:38 +00:00
Matthieu 27413cd08e Fix crashes on older Android versions for save 2024-01-03 16:26:13 +01:00
Matthieu b975a255f9 Fix crash on older Android versions due to cutout 2024-01-03 12:33:10 +01:00
Matthieu 49ec982464 Remove duplicate drawable 2024-01-03 12:08:44 +01:00
Matthieu 9b1573dd8b Remove old permission from AndroidManifest 2023-12-31 13:28:45 +01:00
Matthieu c91b8a391a Fix back button AlbumActivity 2023-12-31 13:28:03 +01:00
Matthieu 7dea955261 Fix share and save of images 2023-12-31 13:27:40 +01:00
Matthieu 69a0c13d32 Remove unneeded file permission checks
This fixes sharing and saving images
2023-12-27 14:08:46 +01:00
Matthieu 5149150f27 Merge branch 'stories' into 'master'
Stories

Closes #368

See merge request pixeldroid/PixelDroid!548
2023-12-27 11:25:36 +00:00
Matthieu 2d988705d5 Release increment and update dependencies 2023-12-27 12:24:07 +01:00
Matthieu d9458f50e3 Update dependency 2023-12-27 11:48:32 +01:00
Matthieu 54eb7f20ef Actually use story settings 2023-12-27 11:47:15 +01:00
Matthieu f3a870d83e Update dependency 2023-12-25 19:55:11 +00:00
Matthieu 89bf93c0e5 Fix crash on image loading in coroutine 2023-12-25 19:55:11 +00:00
Matthieu 77996d26ba Update dependencies 2023-12-25 19:55:11 +00:00
Matthieu f61bf5b1df Improve color 2023-12-25 19:55:11 +00:00
Matthieu 860a639d42 GitLab CI fix for git submodule 2023-12-25 19:55:11 +00:00
Matthieu 1e6c3a9d5a Add TODO for possible performance optimisation of blurhash 2023-12-25 19:55:11 +00:00
Matthieu 8703287d90 Story creation integration 2023-12-25 19:55:11 +00:00
Matthieu bb3c9afb13 Add Self view for stories 2023-12-25 19:55:11 +00:00
Matthieu 0290e6f8d5 Update pixel_common 2023-12-25 19:55:11 +00:00
Matthieu 4008d2a2fc Update dependencies 2023-12-25 19:55:11 +00:00
Matthieu 3fdb7762b6 Use fancier Story progress bars 2023-12-25 19:55:11 +00:00
Matthieu f2c1ae3942 Remove duplicate values that are in pixel_common 2023-12-25 19:55:11 +00:00
Matthieu 4ac7aa6bcb Avoid leaks of bindings 2023-12-25 19:55:11 +00:00
Matthieu 59e29ef232 Clarify Story API 2023-12-25 19:55:11 +00:00
Matthieu f16f1a9927 Remove unused gps permission 2023-12-25 19:55:11 +00:00
Matthieu 6c85115b67 Stories progress 2023-12-25 19:55:11 +00:00
Matthieu f0face3fb0 Update dependencies 2023-12-25 19:55:11 +00:00
Matthieu c40c6f70dc Update dependencies 2023-12-25 19:55:11 +00:00
Matthieu 49d76ff866 Add error mascots to repo 2023-12-25 19:55:11 +00:00
Matthieu 64ffaf081e Move dimensions to library 2023-12-25 19:55:11 +00:00
Matthieu a5334fb637 Fix crash navigating up 2023-12-25 19:55:11 +00:00
Matthieu 70e49ee60c Move from onBackPressed (was deprecated) 2023-12-25 19:55:11 +00:00
Matthieu fc43bc2ff4 Update dependencies 2023-12-25 19:55:11 +00:00
Matthieu d393b083ae Move things AboutActivity and ThemedActivity to library 2023-12-25 19:55:11 +00:00
Matthieu 03e2ab8d49 Center discover tiles in layout 2023-12-25 19:55:11 +00:00
Matthieu 795b54c3cd WIP refactor top bar 2023-12-25 19:55:11 +00:00
Matthieu 8268e9e8b5 Fix encode message showing when it shouldn't 2023-12-25 19:55:11 +00:00
Matthieu eb014290ff Move to automatic generation of locales_config 2023-12-25 19:55:11 +00:00
Matthieu db3da57b7b implement story creation 2023-12-25 19:55:11 +00:00
Matthieu ae54b83ec7 Small improvement to error showing 2023-12-25 19:55:11 +00:00
Matthieu dda06b1cd5 Stories progress 2023-12-25 19:55:11 +00:00
Matthieu 73193abd95 More progress on stories :) 2023-12-25 19:55:11 +00:00
Matthieu f60889ea14 More work on stories 2023-12-25 19:55:11 +00:00
Matthieu 888c6328d9 Super rudimentary support for stories 2023-12-25 19:55:11 +00:00
Matthieu 6876b1c449 Merge branch 'translations' into 'master'
Translations update from Weblate

See merge request pixeldroid/PixelDroid!561
2023-12-21 13:08:56 +00:00
mittwerk a4a2505adb Translated using Weblate (Russian)
Currently translated at 100.0% (241 of 241 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (241 of 241 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (241 of 241 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (241 of 241 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (241 of 241 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (241 of 241 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (241 of 241 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (19 of 19 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (241 of 241 strings)

Translated using Weblate (Russian)

Currently translated at 98.7% (238 of 241 strings)

Co-authored-by: mittwerk <mittwerk3@gmail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/ru/
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/fastlane/ru/
Translation: PixelDroid/Fastlane
Translation: PixelDroid/pixeldroid
2023-12-15 14:32:36 +00:00
Weblate Admin d48e63c65e Translated using Weblate (Dutch)
Currently translated at 48.5% (117 of 241 strings)

Translated using Weblate (Dutch)

Currently translated at 48.1% (116 of 241 strings)

Co-authored-by: Weblate Admin <contact@pixeldroid.org>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/nl/
Translation: PixelDroid/pixeldroid
2023-12-15 14:32:36 +00:00
Matthieu 1956469b7d Merge branch 'translations' into 'master'
Translated using Weblate (Italian)

See merge request pixeldroid/PixelDroid!560
2023-10-04 10:36:26 +00:00
Alex Camilleri 4817188a5b Translated using Weblate (Italian)
Currently translated at 66.3% (160 of 241 strings)

Co-authored-by: Alex Camilleri <camilleri.alex@gmail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/it/
Translation: PixelDroid/pixeldroid
2023-10-03 21:34:58 +00:00
Matthieu 2d61ff855a Merge branch 'translations' into 'master'
Translated using Weblate (Polish)

See merge request pixeldroid/PixelDroid!559
2023-09-14 16:00:54 +00:00
Grzegorz Cichocki aad904dd07 Translated using Weblate (Polish)
Currently translated at 99.1% (239 of 241 strings)

Co-authored-by: Grzegorz Cichocki <grzegorz.cichocki@pollub.edu.pl>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/pl/
Translation: PixelDroid/pixeldroid
2023-09-09 21:05:48 +00:00
Fred f15c23cceb Prepare code for click listener in AlbumActivity 2023-07-30 13:45:33 +02:00
Fred d9da842df7 Create ViewModel for AlbumActivity 2023-07-18 18:22:30 +02:00
Matthieu 88cd015325 Merge branch 'translations' into 'master'
Translated using Weblate (Finnish)

See merge request pixeldroid/PixelDroid!557
2023-06-30 06:38:50 +00:00
Petri Salmela 764d610898 Translated using Weblate (Finnish)
Currently translated at 26.5% (64 of 241 strings)

Co-authored-by: Petri Salmela <pesasa@iki.fi>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/fi/
Translation: PixelDroid/pixeldroid
2023-06-30 03:06:57 +00:00
Matthieu d8adbbfae4 Merge branch 'translations' into 'master'
Translated using Weblate (Persian)

See merge request pixeldroid/PixelDroid!556
2023-06-26 12:19:18 +00:00
Mostafa Ahangarha 0d449551d7 Translated using Weblate (Persian)
Currently translated at 100.0% (241 of 241 strings)

Co-authored-by: Mostafa Ahangarha <ahangarha@riseup.net>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/fa/
Translation: PixelDroid/pixeldroid
2023-06-26 12:06:07 +00:00
Matthieu 8f7cedb508 Merge branch 'artectrex-master-patch-91572' into 'master'
Update .gitlab-ci.yml

See merge request pixeldroid/PixelDroid!555
2023-06-22 12:47:01 +00:00
Matthieu 21c68fdfae Update .gitlab-ci.yml 2023-06-22 12:46:55 +00:00
Matthieu 55a0aaa394 Merge branch 'release' into 'master'
Release

See merge request pixeldroid/PixelDroid!554
2023-06-22 12:19:41 +00:00
Matthieu 99a9470ff9 Release 24 2023-06-22 12:19:33 +00:00
Matthieu cfa254c4f1 Merge branch 'ci-fdroid-fix' into 'master'
Use F-Droid build ci image from mvglasow

See merge request pixeldroid/PixelDroid!553
2023-06-22 12:05:31 +00:00
Matthieu e0e2ef632b Update .gitlab-ci.yml 2023-06-22 12:05:06 +00:00
Matthieu 7e18dec6e8 Update .gitlab-ci.yml 2023-06-22 12:04:36 +00:00
Matthieu aca93b63fb Use f-droid build ci image from mvglasow 2023-06-22 12:02:40 +00:00
Matthieu b2c88d4da3 Merge branch 'translations' into 'master'
Translated using Weblate (Galician)

See merge request pixeldroid/PixelDroid!552
2023-06-17 15:54:36 +00:00
Xose M eb544c9f23 Translated using Weblate (Galician)
Currently translated at 100.0% (241 of 241 strings)

Co-authored-by: Xose M <correoxm@disroot.org>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/gl/
Translation: PixelDroid/pixeldroid
2023-06-17 11:05:29 +00:00
Matthieu 0d45804b0f Merge branch 'translations' into 'master'
Translated using Weblate (German)

See merge request pixeldroid/PixelDroid!551
2023-06-14 14:47:26 +00:00
Niko Diamadis b4f0ea29c0 Translated using Weblate (German)
Currently translated at 94.7% (18 of 19 strings)

Translated using Weblate (German)

Currently translated at 100.0% (239 of 239 strings)

Co-authored-by: Niko Diamadis <niko@cyb3rko.de>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/de/
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/fastlane/de/
Translation: PixelDroid/Fastlane
Translation: PixelDroid/pixeldroid
2023-06-14 14:45:54 +00:00
Matthieu 251110d71f Merge branch 'status-navigation-bar' into 'master'
Improve status and navigation bar colors

See merge request pixeldroid/PixelDroid!549
2023-06-14 14:45:10 +00:00
Cyb3rKo 4a46064c45 Improve status and navigation bar colors 2023-06-14 14:45:10 +00:00
Matthieu ba7becf5f3 Merge branch 'sensitive_always' into 'master'
Add ability to always show sensitive content

Closes #351

See merge request pixeldroid/PixelDroid!550
2023-06-13 20:14:18 +00:00
Matthieu d03314f734 Dependencies 2023-06-13 21:56:33 +02:00
Matthieu 26e7588a75 Reuse value from settings 2023-06-11 23:02:30 +02:00
Adam Williams 43c4520316 Add ability to always show sensitive content 2023-06-11 00:21:50 +01:00
Matthieu b9df1e1a89 Merge branch 'translations' into 'master'
Translated using Weblate (Persian)

See merge request pixeldroid/PixelDroid!547
2023-04-25 11:39:52 +00:00
Mostafa Ahangarha e789e638de Translated using Weblate (Persian)
Currently translated at 63.1% (12 of 19 strings)

Translated using Weblate (Persian)

Currently translated at 100.0% (239 of 239 strings)

Co-authored-by: Mostafa Ahangarha <ahangarha@riseup.net>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/fa/
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/fastlane/fa/
Translation: PixelDroid/Fastlane
Translation: PixelDroid/pixeldroid
2023-04-25 04:28:41 +00:00
Matthieu b57565b25b Merge branch 'translations' into 'master'
Added translation using Weblate (Belarusian)

See merge request pixeldroid/PixelDroid!546
2023-04-09 10:29:22 +00:00
Кірыл Жаркоў abf0d5eee4 Added translation using Weblate (Belarusian)
Co-authored-by: Кірыл Жаркоў <vozhyk@tuta.io>
2023-04-09 10:28:51 +00:00
Matthieu 23e22a6356 Merge branch 'translations' into 'master'
Translated using Weblate (Portuguese (Brazil))

See merge request pixeldroid/PixelDroid!545
2023-04-09 10:28:48 +00:00
Felipe Nogueira 52f271b7f6 Translated using Weblate (Portuguese (Brazil))
Currently translated at 81.5% (195 of 239 strings)

Co-authored-by: Felipe Nogueira <contato.fnog@gmail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/pt_BR/
Translation: PixelDroid/pixeldroid
2023-04-08 00:26:35 +00:00
Matthieu 43d3947891 Merge branch 'translations' into 'master'
Translated using Weblate (Portuguese (Brazil))

See merge request pixeldroid/PixelDroid!544
2023-04-06 11:28:50 +00:00
Felipe Nogueira bd1f0773bc Translated using Weblate (Portuguese (Brazil))
Currently translated at 81.1% (194 of 239 strings)

Co-authored-by: Felipe Nogueira <contato.fnog@gmail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/pt_BR/
Translation: PixelDroid/pixeldroid
2023-04-06 11:26:35 +00:00
Matthieu ada2f41010 Merge branch 'translations' into 'master'
Translated using Weblate (Galician)

See merge request pixeldroid/PixelDroid!543
2023-04-04 07:50:35 +00:00
Xose M e13638b5b6 Translated using Weblate (Galician)
Currently translated at 100.0% (239 of 239 strings)

Translated using Weblate (Galician)

Currently translated at 100.0% (19 of 19 strings)

Co-authored-by: Xose M <correoxm@disroot.org>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/gl/
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/fastlane/gl/
Translation: PixelDroid/Fastlane
Translation: PixelDroid/pixeldroid
2023-04-04 07:50:07 +00:00
Matthieu f8fdba20ba Merge branch 'translations' into 'master'
Translated using Weblate (Galician)

See merge request pixeldroid/PixelDroid!542
2023-04-04 07:50:04 +00:00
Xose M d85ecf8222 Translated using Weblate (Galician)
Currently translated at 100.0% (19 of 19 strings)

Co-authored-by: Xose M <correoxm@disroot.org>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/fastlane/gl/
Translation: PixelDroid/Fastlane
2023-04-04 07:49:31 +00:00
Matthieu d0247aab3f Merge branch 'translations' into 'master'
Translated using Weblate (Portuguese (Brazil))

See merge request pixeldroid/PixelDroid!541
2023-04-04 07:49:26 +00:00
Felipe Nogueira 68fcf9bdf7 Translated using Weblate (Portuguese (Brazil))
Currently translated at 75.7% (181 of 239 strings)

Co-authored-by: Felipe Nogueira <contato.fnog@gmail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/pt_BR/
Translation: PixelDroid/pixeldroid
2023-03-29 01:26:16 +00:00
Matthieu 136d50d4c4 Merge branch 'update-dependencies2' into 'master'
Update dependencies

See merge request pixeldroid/PixelDroid!540
2023-03-28 18:01:11 +00:00
Matthieu aca37c2704 Update dependencies 2023-03-28 17:40:19 +00:00
Matthieu 3fb683006a Merge branch 'translations' into 'master'
Translated using Weblate (Portuguese (Brazil))

See merge request pixeldroid/PixelDroid!539
2023-03-28 17:22:02 +00:00
Felipe Nogueira dfefd6be44 Translated using Weblate (Portuguese (Brazil))
Currently translated at 74.4% (178 of 239 strings)

Co-authored-by: Felipe Nogueira <contato.fnog@gmail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/pt_BR/
Translation: PixelDroid/pixeldroid
2023-03-26 00:26:16 +00:00
Matthieu 37e3387189 Merge branch 'interruptible' into 'master'
Set CI to interruptible

See merge request pixeldroid/PixelDroid!538
2023-03-21 12:05:41 +00:00
Matthieu ad9edc5689 Set CI to interruptible 2023-03-21 11:05:46 +00:00
Matthieu c1eac85385 Merge branch 'update-dependencies' into 'master'
Update dependencies

See merge request pixeldroid/PixelDroid!537
2023-03-21 07:00:36 +00:00
Matthieu 3350b54168 Update dependencies 2023-03-20 22:58:01 +01:00
Matthieu 9abebb209a Merge branch 'translations' into 'master'
Translated using Weblate (Portuguese (Brazil))

See merge request pixeldroid/PixelDroid!536
2023-03-20 17:44:58 +00:00
Felipe Nogueira 1f379dd3f7 Translated using Weblate (Portuguese (Brazil))
Currently translated at 71.1% (170 of 239 strings)

Co-authored-by: Felipe Nogueira <contato.fnog@gmail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/pt_BR/
Translation: PixelDroid/pixeldroid
2023-03-20 17:26:04 +00:00
Matthieu 428c4507c8 Merge branch 'translations' into 'master'
Translated using Weblate (Portuguese (Brazil))

See merge request pixeldroid/PixelDroid!535
2023-03-16 17:43:17 +00:00
Felipe Nogueira 3f097e19bc Translated using Weblate (Portuguese (Brazil))
Currently translated at 69.8% (167 of 239 strings)

Co-authored-by: Felipe Nogueira <contato.fnog@gmail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/pt_BR/
Translation: PixelDroid/pixeldroid
2023-03-16 17:26:04 +00:00
Matthieu 3814ae1be9 Merge branch 'translations' into 'master'
Translated using Weblate (Portuguese (Brazil))

See merge request pixeldroid/PixelDroid!534
2023-03-15 18:27:45 +00:00
Felipe Nogueira 212596947b Translated using Weblate (Portuguese (Brazil))
Currently translated at 69.0% (165 of 239 strings)

Co-authored-by: Felipe Nogueira <contato.fnog@gmail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/pt_BR/
Translation: PixelDroid/pixeldroid
2023-03-15 18:26:04 +00:00
Matthieu 55c8399f1f Merge branch 'translations' into 'master'
Translated using Weblate (Portuguese)

See merge request pixeldroid/PixelDroid!533
2023-03-15 10:28:03 +00:00
Felipe Nogueira 467ea4e85e Translated using Weblate (Portuguese)
Currently translated at 75.7% (181 of 239 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 68.6% (164 of 239 strings)

Co-authored-by: Felipe Nogueira <contato.fnog@gmail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/pt/
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/pt_BR/
Translation: PixelDroid/pixeldroid
2023-03-10 18:26:04 +00:00
Matthieu a1c18b8c27 Merge branch 'translations' into 'master'
Translated using Weblate (Basque)

See merge request pixeldroid/PixelDroid!532
2023-03-03 08:17:04 +00:00
Aitor Salaberria cec4aa0a58 Translated using Weblate (Basque)
Currently translated at 36.8% (7 of 19 strings)

Translated using Weblate (Basque)

Currently translated at 100.0% (239 of 239 strings)

Translated using Weblate (Basque)

Currently translated at 83.2% (199 of 239 strings)

Co-authored-by: Aitor Salaberria <trslbrr@gmail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/eu/
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/fastlane/eu/
Translation: PixelDroid/Fastlane
Translation: PixelDroid/pixeldroid
2023-03-03 09:14:52 +01:00
Matthieu 56e3ac442a Merge branch 'translations' into 'master'
Translated using Weblate (Basque)

See merge request pixeldroid/PixelDroid!531
2023-03-03 08:06:37 +00:00
Aitor Salaberria f4d6773d12 Translated using Weblate (Basque)
Currently translated at 83.2% (199 of 239 strings)

Co-authored-by: Aitor Salaberria <trslbrr@gmail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/eu/
Translation: PixelDroid/pixeldroid
2023-03-02 05:03:49 +00:00
Matthieu e87826efca Merge branch 'translations' into 'master'
Translated using Weblate (Basque)

See merge request pixeldroid/PixelDroid!530
2023-03-01 18:16:36 +00:00
Aitor Salaberria a4cb016d7c Translated using Weblate (Basque)
Currently translated at 79.4% (190 of 239 strings)

Co-authored-by: Aitor Salaberria <trslbrr@gmail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/eu/
Translation: PixelDroid/pixeldroid
2023-03-01 18:04:01 +00:00
Matthieu 9df07abacb Merge branch 'translations' into 'master'
Translated using Weblate (Basque)

See merge request pixeldroid/PixelDroid!529
2023-03-01 10:21:49 +00:00
Aitor Salaberria 5f7d7b5d3b Translated using Weblate (Basque)
Currently translated at 76.9% (184 of 239 strings)

Co-authored-by: Aitor Salaberria <trslbrr@gmail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/eu/
Translation: PixelDroid/pixeldroid
2023-03-01 05:04:02 +00:00
Matthieu a777fe4337 Merge branch 'camera-permission-fix' into 'master'
Camera permission fix, and release

Closes #364

See merge request pixeldroid/PixelDroid!528
2023-02-22 17:30:01 +00:00
Matthieu d8bfb99243 Release 2023-02-22 18:14:51 +01:00
Matthieu 7f1c9b8b16 Rework permission messages. Close #364 2023-02-22 18:00:53 +01:00
Matthieu f87e7dbcc4 Update dependencies 2023-02-22 17:59:21 +01:00
Matthieu 20e86782d9 Merge branch 'translations' into 'master'
Translated using Weblate (Spanish)

See merge request pixeldroid/PixelDroid!527
2023-02-15 16:20:31 +00:00
Andrewblasco 8a5f14e594 Translated using Weblate (Spanish)
Currently translated at 100.0% (18 of 18 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (239 of 239 strings)

Co-authored-by: Andrewblasco <andresbarnaiz@gmail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/es/
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/fastlane/es/
Translation: PixelDroid/Fastlane
Translation: PixelDroid/pixeldroid
2023-02-12 20:28:43 +00:00
Matthieu 42f8b4b0c0 Merge branch 'fix_video_upload' into 'master'
Close #362 and update dependencies

Closes #362

See merge request pixeldroid/PixelDroid!526
2023-02-11 11:37:29 +00:00
Matthieu cdf024b339 Fix #362 and update dependencies 2023-02-11 12:19:56 +01:00
Matthieu 233167d227 Merge branch 'gradle-update' into 'master'
Update scrambler dependency and gradle

See merge request pixeldroid/PixelDroid!523
2023-01-23 09:50:22 +00:00
Matthieu 9ebf11b8df Test java thingie 2023-01-23 09:50:00 +00:00
Matthieu 4ed9c385d6 Update verification metadata 2023-01-23 09:50:00 +00:00
Matthieu 29f09fc0dd test other image 2023-01-23 09:50:00 +00:00
Matthieu e27224725d Update CI 2023-01-23 09:50:00 +00:00
Matthieu 04244bacff Update media editor and remove unused file 2023-01-23 09:50:00 +00:00
Matthieu 713571e4dc Update scrambler dependency and gradle 2023-01-23 09:50:00 +00:00
Matthieu 3d0ec86ac7 Merge branch 'fix_offline_server' into 'master'
Offline server no longer makes app crash

Closes #357

See merge request pixeldroid/PixelDroid!524
2023-01-07 11:26:07 +00:00
fgerber 09f272eb3c Adapt error name to a less generic error name, but still more generic than explicitly telling the server is down (this commit message is longer than the actual changes in the files) 2023-01-07 11:15:10 +00:00
fgerber 1aa9f7bca2 Warn about unknown error instead of server error 2023-01-07 11:15:10 +00:00
fgerber 3c40978d50 Fix crash when server is offline (issue #357) 2023-01-07 11:15:10 +00:00
Matthieu 16ef6d0cbd Merge branch 'translations' into 'master'
Translated using Weblate (Czech)

See merge request pixeldroid/PixelDroid!525
2022-12-19 10:14:35 +00:00
Tomas 513e6e5197 Translated using Weblate (Czech)
Currently translated at 98.7% (235 of 238 strings)

Co-authored-by: Tomas <tomas@brabenec.net>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/cs/
Translation: PixelDroid/pixeldroid
2022-12-19 02:02:47 +00:00
Matthieu 5d80c564e9 Merge branch 'translations' into 'master'
Translated using Weblate (Czech)

See merge request pixeldroid/PixelDroid!522
2022-12-09 13:04:33 +00:00
Tomas f62dd8cc39 Translated using Weblate (Czech)
Currently translated at 81.9% (195 of 238 strings)

Co-authored-by: Tomas <tomas@brabenec.net>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/cs/
Translation: PixelDroid/pixeldroid
2022-12-09 13:03:10 +00:00
Matthieu e3c0be4dbf Merge branch 'translations' into 'master'
Translated using Weblate (Persian)

See merge request pixeldroid/PixelDroid!521
2022-12-04 10:21:24 +00:00
Weblate Admin 4857d0fcb7 Translated using Weblate (Persian)
Currently translated at 95.7% (228 of 238 strings)

Co-authored-by: Weblate Admin <contact@pixeldroid.org>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/fa/
Translation: PixelDroid/pixeldroid
2022-12-04 10:21:00 +00:00
Matthieu a7877e3a26 Merge branch 'translations' into 'master'
Translated using Weblate (Persian)

See merge request pixeldroid/PixelDroid!520
2022-12-04 10:20:59 +00:00
Danial Behzadi 80401169a1 Translated using Weblate (Persian)
Currently translated at 95.7% (228 of 238 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/fa/
Translation: PixelDroid/pixeldroid
2022-12-03 01:02:10 +00:00
Matthieu 4c90591f30 Merge branch 'translations' into 'master'
Translated using Weblate (German)

See merge request pixeldroid/PixelDroid!519
2022-12-02 16:25:23 +00:00
Murat H 87e34a39ec Translated using Weblate (German)
Currently translated at 100.0% (18 of 18 strings)

Translated using Weblate (German)

Currently translated at 100.0% (238 of 238 strings)

Co-authored-by: Murat H <karabela81sta@googlemail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/de/
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/fastlane/de/
Translation: PixelDroid/Fastlane
Translation: PixelDroid/pixeldroid
2022-12-02 16:02:11 +00:00
Matthieu 63d242b12f Merge branch 'translations' into 'master'
Translated using Weblate (Persian)

See merge request pixeldroid/PixelDroid!518
2022-12-02 07:51:12 +00:00
Weblate Admin 47928aa26f Translated using Weblate (Persian)
Currently translated at 93.6% (223 of 238 strings)

Co-authored-by: Weblate Admin <contact@pixeldroid.org>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/fa/
Translation: PixelDroid/pixeldroid
2022-12-02 07:50:50 +00:00
Matthieu e33dd2dff2 Merge branch 'translations' into 'master'
Translated using Weblate (French)

See merge request pixeldroid/PixelDroid!517
2022-12-02 07:41:08 +00:00
ButterflyOfFire 93a4c9db67 Translated using Weblate (French)
Currently translated at 76.8% (183 of 238 strings)

Co-authored-by: ButterflyOfFire <butterflyoffire+pixeldroid@protonmail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/fr/
Translation: PixelDroid/pixeldroid
2022-12-02 07:38:32 +00:00
Matthieu f515ed49e9 Merge branch 'translations' into 'master'
Translated using Weblate (Persian)

See merge request pixeldroid/PixelDroid!516
2022-12-02 07:38:30 +00:00
Danial Behzadi 4ff2ec3c1b Translated using Weblate (Persian)
Currently translated at 93.6% (223 of 238 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/fa/
Translation: PixelDroid/pixeldroid
2022-11-30 16:04:39 +00:00
Matthieu f288474dce Merge branch 'release21_hotfix' into 'master'
fix post upload not working

See merge request pixeldroid/PixelDroid!515
2022-11-30 08:59:37 +00:00
Matthieu 3d5851a664 fix post upload not working 2022-11-30 09:55:15 +01:00
Matthieu ff03e11976 Merge branch 'material_dialogs' into 'master'
Material dialogs

See merge request pixeldroid/PixelDroid!514
2022-11-30 08:52:46 +00:00
Matthieu 75ae26fa47 Material dialogs 2022-11-28 12:30:30 +01:00
Matthieu 5b224976cc Merge branch 'release20' into 'master'
Release 20

See merge request pixeldroid/PixelDroid!513
2022-11-27 23:19:08 +00:00
Matthieu c913adeda6 Release 20 2022-11-27 23:18:24 +00:00
Matthieu 50ddbc40ed Merge branch 'bug_fixes_cleanup' into 'master'
Less crashing for bad json, other bug fixes

See merge request pixeldroid/PixelDroid!512
2022-11-27 23:11:22 +00:00
Matthieu 5dd5057479 Less crashing for bad json, other bug fixes 2022-11-27 23:11:00 +00:00
Matthieu a28be0edf4 Merge branch 'translations' into 'master'
Translated using Weblate (Galician)

See merge request pixeldroid/PixelDroid!511
2022-11-27 21:22:55 +00:00
Xose M 41c72b4650 Translated using Weblate (Galician)
Currently translated at 100.0% (17 of 17 strings)

Translated using Weblate (Galician)

Currently translated at 83.6% (199 of 238 strings)

Co-authored-by: Xose M <correoxm@disroot.org>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/gl/
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/fastlane/gl/
Translation: PixelDroid/Fastlane
Translation: PixelDroid/pixeldroid
2022-11-27 21:22:35 +00:00
Matthieu fb47217e57 Merge branch 'translations' into 'master'
Translated using Weblate (Polish)

See merge request pixeldroid/PixelDroid!510
2022-11-27 21:22:33 +00:00
Murat H 8b68cb6811 Translated using Weblate (German)
Currently translated at 100.0% (17 of 17 strings)

Co-authored-by: Murat H <karabela81sta@googlemail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/fastlane/de/
Translation: PixelDroid/Fastlane
2022-11-26 06:04:38 +00:00
MagT b5478fb0f7 Translated using Weblate (Polish)
Currently translated at 11.7% (2 of 17 strings)

Translated using Weblate (Polish)

Currently translated at 99.5% (237 of 238 strings)

Co-authored-by: MagT <magt@writeme.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/pl/
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/fastlane/pl/
Translation: PixelDroid/Fastlane
Translation: PixelDroid/pixeldroid
2022-11-26 06:04:38 +00:00
Matthieu cbd947fa89 Merge branch 'add_da' into 'master'
add danish

See merge request pixeldroid/PixelDroid!509
2022-11-25 23:17:45 +00:00
Matthieu 09cec0d36a add danish 2022-11-25 23:02:15 +00:00
Matthieu fab2f99581 Merge branch 'add_pt' into 'master'
add portuguese (non-brazil)

See merge request pixeldroid/PixelDroid!508
2022-11-25 22:59:28 +00:00
Matthieu be7113aa25 add portuguese (non-brazil) 2022-11-25 23:48:31 +01:00
Matthieu 3d4e32cf4b Merge branch 'language_chooser_native' into 'master'
Use native language chooser

See merge request pixeldroid/PixelDroid!507
2022-11-25 21:10:22 +00:00
Matthieu e64c2d6399 Fix some bugs 2022-11-25 22:01:36 +01:00
Matthieu 1d6b3c47e7 Fix permissions issue on Android 13 2022-11-25 18:34:34 +01:00
Matthieu 366410e31c Dependencies 2022-11-25 17:09:01 +01:00
Matthieu 67e92a8dfa Merge master 2022-11-25 16:56:48 +01:00
Matthieu 80d205fd74 Use native language chooser 2022-11-25 16:52:27 +01:00
Matthieu 21add3df46 Merge branch 'fix_translations' into 'master'
Fix merge conflicts

See merge request pixeldroid/PixelDroid!506
2022-11-24 13:29:44 +00:00
Matthieu b0bd6d670d Fix merge conflicts 2022-11-24 14:26:55 +01:00
Matthieu d07d35a3dd Merge branch 'translations' into 'master'
Translated using Weblate (German)

See merge request pixeldroid/PixelDroid!505
2022-11-24 13:21:37 +00:00
Murat H 446ad44f4b Translated using Weblate (German)
Currently translated at 94.1% (16 of 17 strings)

Translated using Weblate (German)

Currently translated at 78.5% (187 of 238 strings)

Co-authored-by: Murat H <karabela81sta@googlemail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/de/
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/fastlane/de/
Translation: PixelDroid/Fastlane
Translation: PixelDroid/pixeldroid
2022-11-23 20:04:05 +00:00
Weblate Admin 5abde6608e Translated using Weblate (German)
Currently translated at 78.5% (187 of 238 strings)

Co-authored-by: Weblate Admin <contact@pixeldroid.org>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/de/
Translation: PixelDroid/pixeldroid
2022-11-23 20:04:05 +00:00
Matthieu 4e93c6ec0c Merge branch 'translations' into 'master'
Translated using Weblate (German)

See merge request pixeldroid/PixelDroid!504
2022-11-23 13:20:29 +00:00
Murat H 244452a577 Translated using Weblate (German)
Currently translated at 78.9% (188 of 238 strings)

Co-authored-by: Murat H <karabela81sta@googlemail.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/de/
Translation: PixelDroid/pixeldroid
2022-11-23 13:04:04 +00:00
Matthieu 552b85c59d Merge branch 'fix_redraft' into 'master'
Adapt redraft to new PostSubmissionActivity

See merge request pixeldroid/PixelDroid!498
2022-11-19 22:34:07 +00:00
Matthieu e539ce9232 Improve stuff somewhat 2022-11-19 23:23:11 +01:00
fgerber ca09fba7f3 Adapt remote gradle to pipeline 2022-11-19 20:44:19 +01:00
fgerber aa5c86d711 Merge fix_redraft into master 2022-11-19 15:16:34 +01:00
fgerber 4f3020e0be Restructure post creation activity into two fragments 2022-11-19 13:21:54 +01:00
Matthieu 80d8e40be9 Merge branch 'trendingRefactor' into 'master'
Trending refactor

See merge request pixeldroid/PixelDroid!503
2022-11-19 00:05:19 +00:00
Matthieu 651832d35e Refactor trending activity 2022-11-19 00:57:03 +01:00
Matthieu 4a2234dc78 Merge branch 'translations' into 'master'
Translated using Weblate (Polish)

See merge request pixeldroid/PixelDroid!502
2022-11-14 17:03:48 +00:00
MagT 2fe334d32e Translated using Weblate (Polish)
Currently translated at 87.3% (208 of 238 strings)

Co-authored-by: MagT <magt@writeme.com>
Translate-URL: https://weblate.pixeldroid.org/projects/pixeldroid/app/pl/
Translation: PixelDroid/pixeldroid
2022-11-14 17:02:48 +00:00
Matthieu d5f1358397 Merge branch 'fix_profile_theme2' into 'master'
Fix profile edit theme

Closes #353

See merge request pixeldroid/PixelDroid!501
2022-11-08 22:11:27 +00:00
Matthieu a1ea61a7bd Fix profile edit theme 2022-11-08 22:10:49 +00:00
fgerber ba26871572 Make redraft code more functional, adapt gradle and remove deprecated onBackPressed() 2022-11-04 16:18:01 +01:00
fgerber c879486153 Rebase and update dependencies 2022-11-04 10:16:25 +01:00
fgerber d60bce4b72 Merge branch 'fix_redraft' of gitlab.shinice.net:pixeldroid/PixelDroid into fix_redraft 2022-11-04 00:11:38 +01:00
fgerber 553c65f7bc Refactor redraft as functional code 2022-11-03 23:10:02 +00:00
fgerber 8325067566 Remove useless import 2022-11-03 23:10:02 +00:00
fgerber 2e558017f2 Account for NSFW status in redraft 2022-11-03 23:10:02 +00:00
fgerber 79c2f2a391 Move post deletion to ensure download is complete before 2022-11-03 23:10:02 +00:00
fgerber 20d38d3fa8 Pass existing description through to post submission activity 2022-11-03 23:10:02 +00:00
fgerber 1c8a7d8b7d Refactor redraft as functional code 2022-11-03 23:12:54 +01:00
Matthieu 8237cd03fd Merge branch 'fix_fdroid' into 'master'
Fix F-Droid build

See merge request pixeldroid/PixelDroid!499
2022-11-03 18:05:33 +00:00
Matthieu e6c1ef766d Fix proguard rules 2022-11-03 18:56:46 +01:00
Matthieu d78ef56489 Update permissions 2022-11-03 17:25:28 +01:00
Matthieu c6d43797cc Update dependencies 2022-11-03 15:03:50 +01:00
Matthieu 1481cdc909 Ignore proguard errors 2022-11-03 14:29:36 +01:00
Matthieu c518baa33d Remove extra translations 2022-11-03 14:28:36 +01:00
fgerber c726bbb448 Remove useless import 2022-11-03 13:52:41 +01:00
fgerber e5ff66a83e Account for NSFW status in redraft 2022-11-03 13:22:18 +01:00
fgerber 8ed13cdf2e Move post deletion to ensure download is complete before 2022-11-02 21:52:25 +01:00
fgerber e40a774b1f Pass existing description through to post submission activity 2022-11-02 17:38:55 +01:00
Matthieu 6572f0018a Merge branch 'load_spinner_quest' into 'master'
Make load spinner exist again

Closes #336

See merge request pixeldroid/PixelDroid!497
2022-10-31 00:38:06 +00:00
Matthieu 147046b7b4 Make load spinner exist again 2022-10-31 01:37:44 +01:00
Matthieu 1fa4b80fe7 Merge branch 'profile_description_clicks' into 'master'
Fix profile description clicks

Closes #318

See merge request pixeldroid/PixelDroid!496
2022-10-30 23:59:06 +00:00
Matthieu c21e277485 Fix hashtags and @ in profile description 2022-10-31 00:54:43 +01:00
Matthieu d2c9c1fd47 Merge branch 'multiline_comments' into 'master'
Enable multiline comments

Closes #254

See merge request pixeldroid/PixelDroid!495
2022-10-30 23:27:32 +00:00
Matthieu 35a609613b Enable multiline comments 2022-10-31 00:27:10 +01:00
Matthieu e2ee562841 Merge branch 'carousel_indicator_fix' into 'master'
Carousel indicator fix

See merge request pixeldroid/PixelDroid!494
2022-10-30 23:18:12 +00:00
Matthieu 214ba98bc4 Make carousel indicator count correctly 2022-10-31 00:17:58 +01:00
Matthieu b36fadd76c Merge branch 'post-creation' into 'master'
Refactor of post submission activity

See merge request pixeldroid/PixelDroid!493
2022-10-30 22:40:57 +00:00
Matthieu 5644a22d38 Refactor working 2022-10-30 23:37:14 +01:00
Marie 851d95bf0f Start post submission activity 2022-10-30 20:55:41 +01:00
Matthieu 2d712ed395 Merge branch 'profileEdit' into 'master'
Edit profile in-app

See merge request pixeldroid/PixelDroid!434
2022-10-30 19:52:34 +00:00
Matthieu 2497504530 Basic profile editing 2022-10-30 20:51:09 +01:00
Matthieu e35cb17879 start on profile editing functionality 2022-10-30 13:03:36 +01:00
Matthieu 5b3870b80d Merge branch 'collections' into 'master'
Implement collections

See merge request pixeldroid/PixelDroid!492
2022-10-30 11:46:55 +00:00
Matthieu 085a1f548c Implement collections 2022-10-30 12:34:52 +01:00
Matthieu 2ca9a9b896 Merge branch 'on-boarding' into 'master'
On-boarding

See merge request pixeldroid/PixelDroid!489
2022-10-29 18:01:19 +00:00
Matthieu 06ceb12f23 Finish onboarding document 2022-10-29 19:41:16 +02:00
Marie 849ce3a565 WIP on-boarding 2022-10-29 17:13:06 +00:00
Matthieu 442e07da59 Merge branch 'discover' into 'master'
Search/Discover tab

See merge request pixeldroid/PixelDroid!490
2022-10-29 17:07:59 +00:00
Marie Jaillot cb9180fb60 Search/Discover tab 2022-10-29 17:07:59 +00:00
Matthieu 269276f23d Merge branch 'librarisation' into 'master'
Make library out of editing

See merge request pixeldroid/PixelDroid!491
2022-10-29 17:01:07 +00:00
Matthieu 888b6aecc3 Update editing library dependency 2022-10-29 19:00:37 +02:00
Matthieu 2ecc31e278 Merge branch 'librarisation' of gitlab.shinice.net:pixeldroid/PixelDroid into librarisation 2022-10-29 18:53:34 +02:00
Matthieu 4ccf6deb9c Use plugin from jitpack! 2022-10-29 16:53:19 +00:00
Matthieu b8c6500022 Make resources private with empty public tag 2022-10-29 16:53:19 +00:00
Matthieu 665a1add07 Put editing in a module 2022-10-29 16:53:19 +00:00
Matthieu bb543c3217 Use plugin from jitpack! 2022-10-29 16:51:33 +02:00
Matthieu 963dcad8e4 Make resources private with empty public tag 2022-10-29 08:45:49 +02:00
Matthieu 6b42677f1e Put editing in a module 2022-10-28 20:49:25 +02:00
Matthieu 9bcf587f49 Merge branch 'redraft' into 'master'
Delete and Redraft

See merge request pixeldroid/PixelDroid!483
2022-10-27 13:04:34 +00:00
Matthieu 069c11478a Bug fix redraft and refactor 2022-10-27 14:12:15 +02:00
fgerber 91819dd4db Warn about immediate deletion 2022-10-27 12:35:46 +02:00
fgerber 8b625b43ad Consider posts with multiple pictures/videos and attachment descriptions 2022-10-27 11:45:48 +02:00
Frédéric Gerber 746242eed8 Implement first version of redraft feature 2022-10-27 11:41:40 +02:00
344 changed files with 18918 additions and 17679 deletions

View File

@ -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

3
.gitmodules vendored
View File

@ -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

View File

@ -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 :)

View File

@ -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

View File

@ -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/**/*.*']

View File

@ -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/

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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))
}

View File

@ -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()
}
}
}

View File

@ -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 }
}
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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) } }
}

View File

@ -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
}
}
}

View File

@ -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)

View File

@ -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

View File

@ -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
}

View File

@ -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,
)

View File

@ -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
}
}
}
/**

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}
}
}

View File

@ -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
}
}

View File

@ -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"
}
}

View File

@ -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)
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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)
}
}
}

View File

@ -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) }
}
}

View File

@ -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) {

View File

@ -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

View File

@ -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

View File

@ -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())
}
}
}

View File

@ -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)
}
}

View File

@ -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
}
}
}

View File

@ -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.

View File

@ -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
}

View File

@ -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>

View File

@ -221,8 +221,7 @@ class NotificationsFragment : CachedFeedFragment<Notification>() {
setTextViewFromISO8601(
it,
notificationTime,
false,
itemView.context
false
)
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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
)
}
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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
)
}
}
}

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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,
)

View File

@ -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)

View File

@ -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()

View File

@ -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 {

View File

@ -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)
}
}
}

View File

@ -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 = ""

View File

@ -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
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}
}
}

View File

@ -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()
}
}

View File

@ -1,5 +0,0 @@
package org.pixeldroid.app.settings.licenseObjects
data class Libraries(
val libraries: List<OpenSourceItem>
)

View File

@ -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?,
)

View File

@ -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()
}
}
}

View File

@ -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
}

View File

@ -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!
}
}
}

View File

@ -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?) {}
}

View File

@ -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
}
}

View File

@ -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()
}
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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
}
}

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
}
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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)

View File

@ -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