Compare commits

...

227 Commits

Author SHA1 Message Date
Vitor Pamplona
b4eba2da02 0.92.2 2024-10-11 19:35:52 -04:00
Vitor Pamplona
786a173ba5 Merge pull request #1133 from greenart7c3/external_signer_result
Check for the result field when using external signer
2024-10-11 08:08:30 -04:00
greenart7c3
5ef3c8f00e Check for the result field when using external signer 2024-10-11 07:22:34 -03:00
Vitor Pamplona
d832351bcd Fixes the ability to see muted lists in Shorts 2024-10-10 17:52:24 -04:00
Vitor Pamplona
9ae611831f Enables new fields for vision prescriptions 2024-10-10 16:48:33 -04:00
Vitor Pamplona
6db287a008 Merge pull request #1131 from davotoula/prioritise-name-when-searching
Prioritise search results with user name starting with search term
2024-10-10 09:47:28 -04:00
David Kaspar
99641e1a12 Added comparison between search term and display name to comparator to prioritise results with search term in name 2024-10-10 14:11:13 +02:00
Vitor Pamplona
abb2f0557d Merge pull request #1118 from davotoula/refactor-media-compressor
Refactor media compressor as prep for replacing 3rd party compression library
2024-10-09 16:38:29 -04:00
David Kaspar
76a66e7bd3 added test for when exception happens during compression 2024-10-09 21:46:15 +02:00
David Kaspar
6cd50a115b Merge branch 'vitorpamplona:main' into refactor-media-compressor 2024-10-09 14:16:14 +01:00
Vitor Pamplona
0ed34cec09 Fixes https://github.com/vitorpamplona/amethyst/issues/1130 2024-10-09 08:42:33 -04:00
David Kaspar
c5e3657bfe added missing dot for file extension 2024-10-09 01:45:50 +02:00
David Kaspar
da47965d76 Merge branch 'main' into refactor-media-compressor 2024-10-09 00:16:35 +01:00
David Kaspar
a66417ec82 Added Unknown media type test 2024-10-09 01:07:28 +02:00
David Kaspar
62c3c93d2e Removed splitFileName function as both file name and extension are known separately 2024-10-09 01:02:41 +02:00
David Kaspar
fbecc8b6d1 Use "use" blocks to close resources automatically 2024-10-09 00:52:35 +02:00
Vitor Pamplona
e95afa89ff Merge pull request #1129 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-08 18:38:36 -04:00
Crowdin Bot
d6f2560c1c New Crowdin translations by GitHub Action 2024-10-08 22:33:54 +00:00
Vitor Pamplona
d320b951ae Fixes long term issue with the video in the Shorts feed not aligning with the padding of the screen 2024-10-08 18:31:54 -04:00
David Kaspar
af28e6d466 Use copyTo to copy streams 2024-10-09 00:15:19 +02:00
Vitor Pamplona
86e6d56dcd Removes old logs used for debuging the previous issue 2024-10-08 17:19:35 -04:00
Vitor Pamplona
c66fa1073b Fixes intent reopening the Zap Setup screen because it was saved in the backstack of the Home screen. 2024-10-08 16:09:09 -04:00
Vitor Pamplona
6d9bffe9e7 Disabling #1122 until we solve those little issues. 2024-10-08 15:00:25 -04:00
Vitor Pamplona
04158cec6e Merge pull request #1128 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-08 14:43:07 -04:00
Crowdin Bot
76376a7c40 New Crowdin translations by GitHub Action 2024-10-08 18:32:53 +00:00
Vitor Pamplona
67ea0dcf7f Faster logout processing without closing the account switcher dialog. 2024-10-08 13:59:51 -04:00
David Kaspar
cdc8850894 Refactor out file operations into seperate helper
Additional tests for MediaCompressor
2024-10-08 17:47:27 +02:00
David Kaspar
ce0c7c842d Merge branch 'main' into refactor-media-compressor 2024-10-08 14:51:53 +01:00
Vitor Pamplona
f51f929079 Merging 2024-10-08 09:24:10 -04:00
Vitor Pamplona
d87d2e0ef8 Merge pull request #1127 from geovnn/main
Added animations
2024-10-08 09:22:40 -04:00
Giovanni Gatti
a3166f4ff3 Add animation to notification chart 2024-10-08 12:13:47 +02:00
Giovanni Gatti
9340c440f5 Add animation to FABs 2024-10-08 11:54:42 +02:00
Giovanni Gatti
a7343d5b54 Add animation to zap and reaction popups 2024-10-08 11:06:29 +02:00
Vitor Pamplona
0551b82bd9 Updates AGP and compose, fragment, navigation, benchmarking and firebase libraries. 2024-10-07 17:02:09 -04:00
Vitor Pamplona
18fca8adac Merge pull request #1122 from KotlinGeekDev/conditional-rotation
Re-enable tap to fullscreen behaviour with stricter orientation checks.
2024-10-07 12:57:28 -04:00
Vitor Pamplona
5d788ed669 Merge pull request #1125 from geovnn/main
Prevent clicks outside reaction and zap popups
2024-10-07 12:56:57 -04:00
Giovanni Gatti
54d52e0b77 Prevent clicks outside reaction and zap popups 2024-10-07 17:38:05 +02:00
Vitor Pamplona
28c92675b2 Merge pull request #1124 from greenart7c3/main
Support for login with hex key when using amber
2024-10-07 08:32:59 -04:00
greenart7c3
baa9ee0562 Support for login with hex key when using amber 2024-10-07 08:50:06 -03:00
KotlinGeekDev
7326d6be93 Rotation:actually rotate if the device orientation is not locked. 2024-10-05 17:06:36 +01:00
David Kaspar
e187e5392f refactor "from" method 2024-10-03 14:56:03 +02:00
David Kaspar
7c8f6eb239 formatting 2024-10-03 14:56:03 +02:00
David Kaspar
6294c65ad6 Create private functions for image vs video compression and when statement to select on content type 2024-10-03 14:56:03 +02:00
David Kaspar
51d9657371 Use Pair instead of Array to split up file name 2024-10-03 14:56:03 +02:00
David Kaspar
32783794ee Exit early if UNCOMPRESSED is selected 2024-10-03 14:56:03 +02:00
David Kaspar
e3858dc830 added kotlinx.coroutines.test library
added MediaCompressor first tests
2024-10-03 14:56:03 +02:00
Vitor Pamplona
38a9030963 Merge pull request #1117 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-03 08:28:43 -04:00
Crowdin Bot
29c0c4cf5f New Crowdin translations by GitHub Action 2024-10-03 12:23:07 +00:00
Vitor Pamplona
dcbde81a23 Merge pull request #1116 from davotoula/update-tests-for-new-forceProxy-parameter
Update tests for new forceProxy param
2024-10-03 08:21:47 -04:00
David Kaspar
56e6f06fe7 add "forceProxy = false" to tests 2024-10-03 10:02:31 +02:00
Vitor Pamplona
342b64f0eb Merge pull request #1114 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-30 19:18:57 -04:00
Crowdin Bot
a64a640165 New Crowdin translations by GitHub Action 2024-09-30 23:05:25 +00:00
Vitor Pamplona
6433830c32 Fixes language setting toggles 2024-09-30 18:47:45 -04:00
Vitor Pamplona
3a58819b52 Merge pull request #1112 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-28 11:54:58 -04:00
Crowdin Bot
5c7be44369 New Crowdin translations by GitHub Action 2024-09-28 15:43:33 +00:00
Vitor Pamplona
6e66d008dc Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-28 11:41:30 -04:00
Vitor Pamplona
c61c4ce16c Improves names and icons of the left drawer. 2024-09-28 11:01:56 -04:00
Vitor Pamplona
246ce55253 Merge pull request #1111 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-28 09:49:21 -04:00
Crowdin Bot
a704c81d24 New Crowdin translations by GitHub Action 2024-09-28 13:43:32 +00:00
Vitor Pamplona
9963245ec9 Improves zap store descriptions 2024-09-28 09:40:02 -04:00
Vitor Pamplona
64016aa940 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-27 18:02:33 -04:00
Vitor Pamplona
c6462f102a Merge pull request #1110 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-27 17:23:41 -04:00
Vitor Pamplona
b05083863d removes the aab files 2024-09-27 17:13:44 -04:00
Crowdin Bot
86fffe6539 New Crowdin translations by GitHub Action 2024-09-27 21:13:01 +00:00
Vitor Pamplona
f94a4cfa65 Merge pull request #1109 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-27 17:11:24 -04:00
Crowdin Bot
3825642ae7 New Crowdin translations by GitHub Action 2024-09-27 19:40:05 +00:00
Vitor Pamplona
f90f81b2a5 Merge pull request #1108 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-27 15:38:46 -04:00
Crowdin Bot
bc042e52b0 New Crowdin translations by GitHub Action 2024-09-27 19:28:52 +00:00
Vitor Pamplona
d80b22fa29 Merge pull request #1107 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-27 15:27:38 -04:00
Crowdin Bot
b63ed4fdbd New Crowdin translations by GitHub Action 2024-09-27 19:24:11 +00:00
Vitor Pamplona
397acce85b Merge pull request #1106 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-27 15:23:01 -04:00
Crowdin Bot
5305929df3 New Crowdin translations by GitHub Action 2024-09-27 19:22:04 +00:00
Vitor Pamplona
6d4ba6d507 v0.92.1 2024-09-27 15:20:06 -04:00
Vitor Pamplona
a028c2c3fc Fixes missing zaps on the first connection to a NWC wallet with a split payment. 2024-09-27 15:19:59 -04:00
Vitor Pamplona
86e5dfa799 Fixes access to Citrine 2024-09-27 14:38:14 -04:00
Vitor Pamplona
3aa316d262 v0.92.0 2024-09-27 11:14:45 -04:00
Vitor Pamplona
3f4efdb6d9 Merge pull request #1105 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-27 08:26:41 -04:00
Crowdin Bot
a178e5f589 New Crowdin translations by GitHub Action 2024-09-27 12:23:29 +00:00
Vitor Pamplona
cc5a6cdaf2 Merge pull request #1104 from davotoula/update_translations
Update de, cs, se, pt translations
2024-09-27 08:21:49 -04:00
David Kaspar
d9596335de Update de, cs, se, pt translations 2024-09-27 09:51:41 +02:00
Vitor Pamplona
44c600dfa8 Fixes icon position on chat user pictures 2024-09-26 18:36:42 -04:00
Vitor Pamplona
a92fe0bca2 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-26 17:20:36 -04:00
Vitor Pamplona
dea9d698df Adds privacy presets for Tor Settings 2024-09-26 17:20:29 -04:00
Vitor Pamplona
94ae7b3da1 Merge pull request #1103 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-26 15:53:03 -04:00
Crowdin Bot
da1c94f77b New Crowdin translations by GitHub Action 2024-09-26 19:26:05 +00:00
Vitor Pamplona
64ff33e745 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-26 15:23:47 -04:00
Vitor Pamplona
8810cfde37 Adjusts Login and Sign up screens for the new Tor Settings 2024-09-26 15:23:39 -04:00
Vitor Pamplona
c7b7ad78b7 Adds mempool to verify OTS via Tor. 2024-09-26 15:23:13 -04:00
Vitor Pamplona
ff9f25ccdb Removes unnecessary log 2024-09-26 14:42:40 -04:00
Vitor Pamplona
498595b892 Merge pull request #1102 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-26 13:48:48 -04:00
Vitor Pamplona
56878900b0 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-26 13:46:52 -04:00
Vitor Pamplona
d8d6736d55 Caches OTS web calls to avoid pinging the server repeatedly for the same event. 2024-09-26 13:45:01 -04:00
Crowdin Bot
ddc68e61d5 New Crowdin translations by GitHub Action 2024-09-26 17:43:05 +00:00
Vitor Pamplona
d4e8fc5737 Merge pull request #1101 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-26 13:41:24 -04:00
Crowdin Bot
3a7dfe7d03 New Crowdin translations by GitHub Action 2024-09-26 15:57:17 +00:00
Vitor Pamplona
d23a859f34 Improves OTS Verification error message 2024-09-26 11:55:29 -04:00
Vitor Pamplona
824cb4e5f1 @Suppress("ProduceStateDoesNotAssignValue") until we figure out what's wrong with this lint. 2024-09-26 11:36:52 -04:00
Vitor Pamplona
35aeeb571c Fixes setting the value in the main thread 2024-09-26 11:20:51 -04:00
Vitor Pamplona
70d68c89c8 Updates compose, benchmark, firebase, navigation and lifecycle dependencies. 2024-09-26 11:20:33 -04:00
Vitor Pamplona
35a6331da2 Fixes test case 2024-09-26 10:59:50 -04:00
Vitor Pamplona
6229a0ad63 making sure to only use .onion urls if the option is active 2024-09-26 10:41:36 -04:00
Vitor Pamplona
620285596d Show buffering when loading videos 2024-09-26 10:39:12 -04:00
Vitor Pamplona
fd2637ff42 Removes unused tor setting 2024-09-26 10:38:36 -04:00
Vitor Pamplona
afe8a06486 - Adds a new Settings page for Tor
- Adjusts all web calls to use Tor if that setting is enabled.
- Allows .onion urls to use Tor
- Blocks localhost from using Tor
- Moves OTS web calls to use the Tor Proxy as well.
- Starts to build all OkHttp clients from a main root node to keep the same thread pool
- Refactors the URL Preview code
- Changes ammolite to force proxy.
- Changes NIP-47 implementation to force relay for the NWC connection.
2024-09-25 17:58:37 -04:00
Vitor Pamplona
b682e90f81 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-23 21:51:23 -04:00
Vitor Pamplona
ec700ca61f Refactoring 2024-09-23 21:51:15 -04:00
Vitor Pamplona
9b26ffd92a Updates to AGP 8.6.1 2024-09-23 21:48:13 -04:00
Vitor Pamplona
a8ca9ab727 Merge pull request #1091 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-20 09:00:43 -04:00
Crowdin Bot
e5832f6a45 New Crowdin translations by GitHub Action 2024-09-20 12:57:12 +00:00
Vitor Pamplona
8b3035f635 Merge pull request #1090 from greenart7c3/main
Fix for notifications when using amber
2024-09-20 08:56:00 -04:00
greenart7c3
9f17cc50f8 Fix for notifications when using amber 2024-09-20 06:48:27 -03:00
Vitor Pamplona
d0633d07fa Fixes inability to go back after opening a nostr link from the web into Amethyst 2024-09-18 19:47:09 -04:00
Vitor Pamplona
97b88c7ceb Merge pull request #1088 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-18 17:38:59 -04:00
Crowdin Bot
4961a22753 New Crowdin translations by GitHub Action 2024-09-18 21:23:22 +00:00
Vitor Pamplona
45b568f6a7 updates amy bg 2024-09-18 17:21:44 -04:00
Vitor Pamplona
446859e8bd adds amy 2024-09-18 17:16:52 -04:00
Vitor Pamplona
e1b24c22b9 adds phone mockups 2024-09-18 17:16:38 -04:00
Vitor Pamplona
e0b1c4dc3f Sending jpg and webp version of the image 2024-09-18 16:28:07 -04:00
Vitor Pamplona
5722507b3d Adds two designed images for the homepage 2024-09-18 16:23:13 -04:00
Vitor Pamplona
74df309527 Improving bug report template 2024-09-17 18:07:41 -04:00
Vitor Pamplona
740c6a8c33 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-17 18:07:29 -04:00
Vitor Pamplona
929a263665 Adds zapstore yaml 2024-09-17 18:07:12 -04:00
Vitor Pamplona
6f44c02b5c Makes sure if the d component of an a-tag has :, it stays in the d-tag after parse. 2024-09-16 18:10:54 -04:00
Vitor Pamplona
bce01dc264 Merge pull request #1083 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-16 11:26:36 -04:00
Crowdin Bot
7919637e12 New Crowdin translations by GitHub Action 2024-09-16 15:25:34 +00:00
Vitor Pamplona
28da06ef01 Fixes #1084. Opengraph parser fails when value-less properties are present. 2024-09-16 11:23:41 -04:00
Vitor Pamplona
428ed464af Adds salt to the bloom filter 2024-09-15 14:28:49 -04:00
Vitor Pamplona
5921b712b0 Merge pull request #1082 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-13 14:17:46 -04:00
Crowdin Bot
5c9a37f01c New Crowdin translations by GitHub Action 2024-09-13 18:12:12 +00:00
Vitor Pamplona
514a69deec Bloom filter test case for NIP76 2024-09-13 14:06:45 -04:00
Vitor Pamplona
356cbc24d4 Merge pull request #1081 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-12 16:09:38 -04:00
Crowdin Bot
f4a2f247f7 New Crowdin translations by GitHub Action 2024-09-12 20:05:59 +00:00
Vitor Pamplona
9ee28e28b8 If already logged in and an NFC tag shows up with ncryptsec, logs in as a transient account 2024-09-12 15:57:35 -04:00
Vitor Pamplona
2457429470 Merge pull request #1078 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-12 08:15:27 -04:00
Crowdin Bot
b998381bb6 New Crowdin translations by GitHub Action 2024-09-12 12:04:40 +00:00
Vitor Pamplona
74725235b7 Merge pull request #1074 from davotoula/add-media-compression-level-uncompressed
Add media compression level uncompressed
2024-09-12 08:02:51 -04:00
David Kaspar
ef4d975c7c Merge branch 'vitorpamplona:main' into add-media-compression-level-uncompressed 2024-09-12 12:40:46 +01:00
Vitor Pamplona
3fdd3f9b69 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-11 17:15:33 -04:00
Vitor Pamplona
53aa9b0017 Trying to fix upload-artifact. 2024-09-11 17:15:23 -04:00
Vitor Pamplona
a52d87a555 Merge pull request #1076 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-11 17:15:10 -04:00
Crowdin Bot
39f062fe32 New Crowdin translations by GitHub Action 2024-09-11 21:03:20 +00:00
Vitor Pamplona
b2c13089ce adds NFC-Based transient accounts.
Refactors the login screen
2024-09-11 16:49:35 -04:00
David Kaspar
4ddf307b9f added translations 2024-09-11 17:27:08 +02:00
David Kaspar
3db36d8c67 added skipping of media compressor when quality is UNCOMPRESSED
added media compressor logging
2024-09-11 17:27:08 +02:00
David Kaspar
bba7d3455c enhanced nip05 logging 2024-09-11 17:27:08 +02:00
Vitor Pamplona
f086c0fe10 Merge pull request #1043 from greenart7c3/camera
Take pictures with camera
2024-09-10 15:32:01 -04:00
Vitor Pamplona
abc803953e Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-09 19:44:33 -04:00
Vitor Pamplona
8841aa9a10 Adds Tor 2024-09-09 19:42:43 -04:00
greenart7c3
7e417f09f7 Merge remote-tracking branch 'origin/camera' into camera 2024-09-09 16:10:47 -03:00
greenart7c3
0fde6ffd30 Move camera dependencies to version catalogs 2024-09-09 16:03:35 -03:00
greenart7c3
6f291139d8 Merge branch 'main' into camera 2024-09-09 15:40:56 -03:00
greenart7c3
1e3104d489 Add camera button in the stories screen 2024-09-09 15:37:30 -03:00
greenart7c3
2e110422c8 after enabling permissions open the camera 2024-09-09 14:59:05 -03:00
Vitor Pamplona
7b7aa1932f Merge pull request #1072 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-09 13:37:59 -04:00
Crowdin Bot
773c96997c New Crowdin translations by GitHub Action 2024-09-09 17:37:07 +00:00
Vitor Pamplona
8f8199f489 Merge pull request #1071 from greenart7c3/main
Fix edit draft not working when using the quick action menu
2024-09-09 13:33:54 -04:00
greenart7c3
a63d50c24f Merge remote-tracking branch 'origin/camera' into camera 2024-09-09 14:32:55 -03:00
greenart7c3
6af2b48d23 Add a button to take a picture from camera 2024-09-09 14:32:37 -03:00
greenart7c3
37197e1f8a Fix edit draft not working when using the quick action menu 2024-09-09 14:25:11 -03:00
Vitor Pamplona
8ede4eb3d6 Updates navigation compose, lifecycle, fragment, activity and composeBoms 2024-09-06 17:29:06 -04:00
Vitor Pamplona
c7fcc47b11 Adds an isRefreshing status for all feeds. 2024-09-06 17:19:10 -04:00
Vitor Pamplona
f12cf93e9f Merge pull request #1065 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-06 14:20:11 -04:00
Crowdin Bot
00e354394f New Crowdin translations by GitHub Action 2024-09-06 18:12:34 +00:00
Vitor Pamplona
bbb548fe21 Fixes the zap options available for the Zap the Devs button. 2024-09-06 11:36:01 -04:00
Vitor Pamplona
c19e1fc56d Fixes #1064 2024-09-06 11:19:01 -04:00
Vitor Pamplona
065cf83b68 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-06 11:00:04 -04:00
Vitor Pamplona
ca0da1dcc8 v0.91.0 2024-09-06 10:59:54 -04:00
Vitor Pamplona
58a5cbc624 Merge pull request #1063 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-06 10:12:13 -04:00
Crowdin Bot
554fef9eae New Crowdin translations by GitHub Action 2024-09-06 14:10:20 +00:00
Vitor Pamplona
7dd76f866a Enables the use of hidden words for all visible properties of the user 2024-09-06 10:07:41 -04:00
Vitor Pamplona
84bcc33e87 minor refactoring on variables inside the chat screen rendering. 2024-09-06 10:03:49 -04:00
Vitor Pamplona
6309aa1711 Fixes size of default banner when the profile is not loaded yet. 2024-09-06 10:02:04 -04:00
Vitor Pamplona
a8a3bc8a6b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-05 18:00:25 -04:00
Vitor Pamplona
d2b4a859bf Fixes Community Load up with Global filter 2024-09-05 18:00:17 -04:00
Vitor Pamplona
92d11d84a7 Merge pull request #1061 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-05 17:49:28 -04:00
Crowdin Bot
1cba6e6cdb New Crowdin translations by GitHub Action 2024-09-05 21:41:52 +00:00
Vitor Pamplona
20115c7080 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-05 17:39:51 -04:00
Vitor Pamplona
4b3d986c5b Moves has-new-item bottom navigation computations to use the new feed states and last read database in flows. 2024-09-05 17:39:36 -04:00
Vitor Pamplona
21112d3d46 Merge pull request #1060 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-05 15:23:45 -04:00
Crowdin Bot
ec3f307e0b New Crowdin translations by GitHub Action 2024-09-05 19:17:01 +00:00
Vitor Pamplona
e17cfb4fdb Simplifies the "and 2 more" translations 2024-09-05 15:11:00 -04:00
Vitor Pamplona
010305fbeb - Disables saving m3u8 files (streaming can't be saved)
- Changes Error messages to not include stack traces and move them to a copy to clipboard action instead.
2024-09-05 14:36:23 -04:00
Vitor Pamplona
5af473870e Fixes Search layout after the transition to nav 2024-09-05 14:00:39 -04:00
Vitor Pamplona
80f805617a Adds sliding animations in all inner screens 2024-09-05 13:53:52 -04:00
Vitor Pamplona
3e1184562f Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-05 13:47:33 -04:00
Vitor Pamplona
39edbd22cc fixes #1058 language update issue 2024-09-05 13:47:23 -04:00
Vitor Pamplona
c90e615d5b Merge pull request #1059 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-05 13:00:43 -04:00
Crowdin Bot
5e6e7a3b5f New Crowdin translations by GitHub Action 2024-09-05 16:08:57 +00:00
Vitor Pamplona
3e23d6e526 Merge pull request #1051 from davotoula/configurable-media-compression
Configurable media compression
2024-09-05 12:07:33 -04:00
David Kaspar
08054e226e Merge branch 'vitorpamplona:main' into configurable-media-compression 2024-09-05 16:02:52 +01:00
David Kaspar
cfe668ab0d move slider value label to above slider 2024-09-05 16:58:39 +02:00
Vitor Pamplona
8f19276fac Fixes blinking on crossfades when the system's light/dark theme is different than the app's theme. 2024-09-05 10:50:16 -04:00
Vitor Pamplona
3a3c0d74d2 Fixes the Messages layout on foldables. 2024-09-05 10:30:58 -04:00
David Kaspar
3ad21e72a9 Clean up by using Int instead of Float for compression level 2024-09-05 16:10:26 +02:00
David Kaspar
790b9359bd Merge branch 'vitorpamplona:main' into configurable-media-compression 2024-09-05 14:32:59 +01:00
David Kaspar
e52e2bfdc9 Localise and add czech, swedish, german and PT translations 2024-09-05 15:09:19 +02:00
Vitor Pamplona
a34c763656 Merge pull request #1056 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-05 08:42:51 -04:00
Crowdin Bot
adf5bbe80f New Crowdin translations by GitHub Action 2024-09-05 12:39:02 +00:00
Vitor Pamplona
c4c542c739 Moves the last npub to hexkey for https://github.com/nostr-protocol/nips/pull/1341 2024-09-05 08:37:15 -04:00
David Kaspar
5f0236c015 change MediaCompressor API: mandatory mediaQuality 2024-09-05 14:28:12 +02:00
David Kaspar
023c0592e8 Add compression configuration to VideoScreen uploads 2024-09-05 13:09:17 +02:00
David Kaspar
c6d2f05f15 Styling 2024-09-05 11:50:52 +02:00
David Kaspar
08e9f6625a Merge branch 'main' into configurable-media-compression 2024-09-05 08:32:49 +01:00
David Kaspar
fcf5e972d6 Passing media quality from Slider to Compressor
setting media quality to MEDIUM where ui has no option for setting it (ChannelScreen, ChatroomScreen, NewUserMetadataViewModel)
2024-09-05 09:09:10 +02:00
Vitor Pamplona
20bf4dbc29 Merge pull request #1053 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-04 22:28:12 -04:00
Crowdin Bot
990c5df2b9 New Crowdin translations by GitHub Action 2024-09-05 02:21:58 +00:00
Vitor Pamplona
71af88725a Fixes play version 2024-09-04 22:19:28 -04:00
Vitor Pamplona
fb5f5c3eba no message 2024-09-04 22:18:00 -04:00
Vitor Pamplona
ead588314e - Inverts Layouts to place Navigation on top of Scaffold and allow custom scaffolds per route.
- Finishes Edge to Edge transition
- Fixes moving buttons on the full screen dialog for images and videos.
- Restructures screens into their own packages.
- Restructures navigation functions as a single object
- Fixes padding of the backup screen
- Refactors all TopBars to use default material 3 ones.
- Refactors navigation to improve clarity
- Removes unnecessary observers from the transition in the bottom nav layouts.
2024-09-04 22:17:42 -04:00
Vitor Pamplona
fb447e441a Merge pull request #1052 from greenart7c3/gcompatup
Fix token sanitization when using gcompatup
2024-09-04 13:37:42 -04:00
greenart7c3
3d61343905 Fix token sanitization when using gcompatup 2024-09-04 13:58:55 -03:00
David Kaspar
7e4d9fb708 added media quality slider 2024-09-04 12:32:33 +02:00
Vitor Pamplona
0217fd3eb3 Moves the profile zap to threads 2024-09-03 17:29:41 -04:00
Vitor Pamplona
96fa6f39b0 Fixes ime padding issues when using physical keyboards on the message screens. 2024-09-03 17:28:07 -04:00
Vitor Pamplona
b6bb214532 Fixes text field recompositions because of new keyboard actions 2024-09-03 15:55:52 -04:00
Vitor Pamplona
ee160b76a8 Updates zoomable and kotlin serialization 2024-09-03 15:54:54 -04:00
Vitor Pamplona
2fd9d6610a Normalizes cache directories in the Application class 2024-09-03 15:31:16 -04:00
David Kaspar
272f99be17 added logging 2024-09-03 20:44:39 +02:00
David Kaspar
b8246d7cd4 fixing type 2024-09-03 19:23:47 +02:00
David Kaspar
981d93f2fc making MediaCompressor configurable 2024-09-03 19:18:10 +02:00
Vitor Pamplona
1166fe92e9 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-03 11:45:27 -04:00
Vitor Pamplona
b700d4e9bf Fixes missing Private Home/Outbox relay list after loading from backup 2024-09-03 11:43:17 -04:00
Vitor Pamplona
0b41d09fda Merge pull request #1050 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-02 16:00:29 -04:00
Crowdin Bot
61c145a68f New Crowdin translations by GitHub Action 2024-09-02 19:29:46 +00:00
Vitor Pamplona
4103342f99 Updates to AGP 8.6.0 2024-09-02 15:24:39 -04:00
Vitor Pamplona
e48ed607ff Fixes #1049 2024-09-02 14:56:24 -04:00
greenart7c3
a03e02bfeb Add a button to take a picture from camera 2024-09-02 08:42:46 -03:00
332 changed files with 10980 additions and 6314 deletions

View File

@@ -24,9 +24,10 @@ A clear and concise description of what you expected to happen.
If applicable, add a video and/or screenshots to help explain your problem.
**Device (please complete the following information):**
- Phone Brand/Model [e.g. Pixel 7 Pro]:
- Android Version [e.g. 33]:
- App Version [e.g. v0.20.3]:
- Phone Brand/Model [e.g. Pixel 7 Pro]:
- Android Version [e.g. 33]:
- App Version [e.g. v0.20.3]:
- Amber Version (if using it to sign):
**Bounty (in Bitcoin sats) offered for a solution**
Incentivize developers to work on your issue. Describe clear milestones to claim payment.

View File

@@ -43,13 +43,13 @@ jobs:
run: ./gradlew assembleDebug --no-daemon
- name: Upload APK
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
with:
name: Debug APK
path: amethyst/build/outputs/apk/debug/app-debug.apk
- name: Upload Test Results
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
with:
name: Build Reports
path: amethyst/build/reports

1
.gitignore vendored
View File

@@ -13,6 +13,7 @@
/.idea/ktfmt.xml
/.idea/studiobot.xml
/.idea/other.xml
/.idea/runConfigurations.xml
.DS_Store
/build
/captures

View File

@@ -3,15 +3,19 @@
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />

View File

@@ -16,9 +16,9 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk libs.versions.android.minSdk.get().toInteger()
targetSdk libs.versions.android.targetSdk.get().toInteger()
versionCode 403
versionName "0.90.6"
buildConfigField "String", "RELEASE_NOTES_ID", "\"5d9a4fd0aba7ffefbf989152479237bd182578e58e59eb2fbfe94f6de8011803\""
versionCode 407
versionName "0.92.2"
buildConfigField "String", "RELEASE_NOTES_ID", "\"98f2acaf9b3eb70d87607942e576f9a086ac28adf37869da9a29f4815cecac86\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -288,8 +288,12 @@ dependencies {
// Cbor for cashuB format
implementation libs.kotlinx.serialization.cbor
implementation libs.tor.android
implementation libs.jtorctl
testImplementation libs.junit
testImplementation libs.mockk
testImplementation libs.kotlinx.coroutines.test
androidTestImplementation platform(libs.androidx.compose.bom)
androidTestImplementation libs.androidx.junit
@@ -299,5 +303,11 @@ dependencies {
debugImplementation platform(libs.androidx.compose.bom)
debugImplementation libs.androidx.ui.tooling
debugImplementation libs.androidx.ui.test.manifest
implementation libs.androidx.camera.core
implementation libs.androidx.camera.camera2
implementation libs.androidx.camera.lifecycle
implementation libs.androidx.camera.view
implementation libs.androidx.camera.extensions
}

View File

@@ -52,6 +52,7 @@ class ImageUploadTesting {
Nip96Retriever()
.loadInfo(
server.baseUrl,
false,
)
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888)
@@ -80,6 +81,7 @@ class ImageUploadTesting {
alt = null,
sensitiveContent = null,
serverInfo,
forceProxy = { false },
onProgress = {},
context = InstrumentationRegistry.getInstrumentation().targetContext,
)
@@ -106,7 +108,7 @@ class ImageUploadTesting {
Assert.assertTrue("${server.name}: Invalid result url", url.startsWith("http"))
val imageData: ByteArray =
ImageDownloader().waitAndGetImage(url)
ImageDownloader().waitAndGetImage(url, false)
?: run {
fail("${server.name}: Should not be null")
return

View File

@@ -46,7 +46,7 @@ class OkHttpOtsTest {
@Before
fun setup() {
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(), OkHttpCalendarBuilder())
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(forceProxy = { false }), OkHttpCalendarBuilder(forceProxy = { false }))
}
@Test

View File

@@ -38,7 +38,6 @@ interface PushDistributorActions {
}
object PushDistributorHandler : PushDistributorActions {
private val appContext = Amethyst.instance.applicationContext
private val unifiedPush: UnifiedPush = UnifiedPush
private var endpointInternal = ""
@@ -54,11 +53,13 @@ object PushDistributorHandler : PushDistributorActions {
endpointInternal = ""
}
override fun getSavedDistributor(): String = unifiedPush.getSavedDistributor(appContext) ?: ""
fun appContext(): Context = Amethyst.instance.applicationContext
override fun getSavedDistributor(): String = unifiedPush.getSavedDistributor(appContext()) ?: ""
fun savedDistributorExists(): Boolean = getSavedDistributor().isNotEmpty()
override fun getInstalledDistributors(): List<String> = unifiedPush.getDistributors(appContext)
override fun getInstalledDistributors(): List<String> = unifiedPush.getDistributors(appContext())
fun formattedDistributorNames(): List<String> {
val distributorsArray = getInstalledDistributors().toTypedArray()
@@ -68,16 +69,16 @@ object PushDistributorHandler : PushDistributorActions {
try {
val ai =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
appContext.packageManager.getApplicationInfo(
appContext().packageManager.getApplicationInfo(
it,
PackageManager.ApplicationInfoFlags.of(
PackageManager.GET_META_DATA.toLong(),
),
)
} else {
appContext.packageManager.getApplicationInfo(it, 0)
appContext().packageManager.getApplicationInfo(it, 0)
}
appContext.packageManager.getApplicationLabel(ai)
appContext().packageManager.getApplicationLabel(ai)
} catch (e: PackageManager.NameNotFoundException) {
it
}
@@ -87,12 +88,12 @@ object PushDistributorHandler : PushDistributorActions {
}
override fun saveDistributor(distributor: String) {
unifiedPush.saveDistributor(appContext, distributor)
unifiedPush.registerApp(appContext)
unifiedPush.saveDistributor(appContext(), distributor)
unifiedPush.registerApp(appContext())
}
override fun removeSavedDistributor() {
unifiedPush.safeRemoveDistributor(appContext)
unifiedPush.safeRemoveDistributor(appContext())
}
fun forceRemoveDistributor(context: Context) {

View File

@@ -84,7 +84,7 @@ class PushMessageReceiver : MessagingReceiver() {
endpoint: String,
instance: String,
) {
val sanitizedEndpoint = endpoint.dropLast(5)
val sanitizedEndpoint = if (endpoint.endsWith("?up=1")) endpoint.dropLast(5) else endpoint
if (sanitizedEndpoint != pushHandler.getSavedEndpoint()) {
Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance ${pushHandler.getSavedEndpoint()} $sanitizedEndpoint")
pushHandler.setEndpoint(sanitizedEndpoint)

View File

@@ -55,10 +55,10 @@ import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SettingsRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.checkifItNeedsToRequestNotificationPermission
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList

View File

@@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.events.ImmutableListOfLists
@@ -38,7 +39,7 @@ fun TranslatableRichTextViewer(
id: String,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) = ExpandableRichTextViewer(
content,
canPreview,

View File

@@ -16,6 +16,9 @@
<!-- To connect with relays -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- To take pictures -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- To Upload media (newer devices) -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
@@ -121,6 +124,16 @@
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -32,11 +32,8 @@ import androidx.security.crypto.EncryptedSharedPreferences
import coil.ImageLoader
import coil.disk.DiskCache
import coil.memory.MemoryCache
import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
import com.vitorpamplona.amethyst.service.playback.VideoCache
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.ots.OpenTimestamps
import com.vitorpamplona.ammolite.service.HttpClientManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -59,10 +56,15 @@ class Amethyst : Application() {
applicationIOScope.cancel()
}
fun nip95cache() = safeCacheDir.resolve("NIP95")
val videoCache: VideoCache by lazy {
val newCache = VideoCache()
runBlocking {
newCache.initFileCache(this@Amethyst)
newCache.initFileCache(
this@Amethyst,
safeCacheDir.resolve("exoplayer"),
)
}
newCache
}
@@ -70,7 +72,7 @@ class Amethyst : Application() {
val coilCache: DiskCache by lazy {
DiskCache
.Builder()
.directory(applicationContext.safeCacheDir.resolve("image_cache"))
.directory(safeCacheDir.resolve("image_cache"))
.maxSizePercent(0.2)
.maximumMaxSizeBytes(1024 * 1024 * 1024) // 1GB
.build()
@@ -89,7 +91,7 @@ class Amethyst : Application() {
instance = this
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(), OkHttpCalendarBuilder())
HttpClientManager.setDefaultUserAgent("Amethyst/${BuildConfig.VERSION_NAME}")
if (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark") {
StrictMode.setThreadPolicy(

View File

@@ -0,0 +1,180 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
import android.app.ActivityManager
import android.content.Context
import android.content.pm.ApplicationInfo
import android.os.Debug
import android.util.Log
import androidx.core.content.getSystemService
import coil.Coil
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrCommunityDataSource
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
import com.vitorpamplona.amethyst.service.NostrGeohashDataSource
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.ammolite.relays.Client
import com.vitorpamplona.ammolite.relays.RelayPool
fun debugState(context: Context) {
Client
.allSubscriptions()
.forEach { Log.d("STATE DUMP", "${it.key} ${it.value.joinToString { it.filter.toDebugJson() }}") }
NostrAccountDataSource.printCounter()
NostrChannelDataSource.printCounter()
NostrChatroomDataSource.printCounter()
NostrChatroomListDataSource.printCounter()
NostrCommunityDataSource.printCounter()
NostrDiscoveryDataSource.printCounter()
NostrHashtagDataSource.printCounter()
NostrGeohashDataSource.printCounter()
NostrHomeDataSource.printCounter()
NostrSearchEventOrUserDataSource.printCounter()
NostrSingleChannelDataSource.printCounter()
NostrSingleEventDataSource.printCounter()
NostrSingleUserDataSource.printCounter()
NostrThreadDataSource.printCounter()
NostrUserProfileDataSource.printCounter()
NostrVideoDataSource.printCounter()
val totalMemoryMb = Runtime.getRuntime().totalMemory() / (1024 * 1024)
val freeMemoryMb = Runtime.getRuntime().freeMemory() / (1024 * 1024)
val maxMemoryMb = Runtime.getRuntime().maxMemory() / (1024 * 1024)
val jvmHeapAllocatedMb = totalMemoryMb - freeMemoryMb
Log.d("STATE DUMP", "Total Heap Allocated: " + jvmHeapAllocatedMb + "/" + maxMemoryMb + " MB")
val nativeHeap = Debug.getNativeHeapAllocatedSize() / (1024 * 1024)
val maxNative = Debug.getNativeHeapSize() / (1024 * 1024)
Log.d("STATE DUMP", "Total Native Heap Allocated: " + nativeHeap + "/" + maxNative + " MB")
val activityManager: ActivityManager? = context.getSystemService()
if (activityManager != null) {
val isLargeHeap = (context.applicationInfo.flags and ApplicationInfo.FLAG_LARGE_HEAP) != 0
val memClass = if (isLargeHeap) activityManager.largeMemoryClass else activityManager.memoryClass
Log.d("STATE DUMP", "Memory Class " + memClass + " MB (largeHeap $isLargeHeap)")
}
Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays())
val imageLoader = Coil.imageLoader(context)
Log.d(
"STATE DUMP",
"Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB",
)
Log.d(
"STATE DUMP",
"Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB",
)
Log.d(
"STATE DUMP",
"Notes: " +
LocalCache.notes.filter { _, it -> it.liveSet != null }.size +
" / " +
LocalCache.notes.filter { _, it -> it.flowSet != null }.size +
" / " +
LocalCache.notes.filter { _, it -> it.event != null }.size +
" / " +
LocalCache.notes.size(),
)
Log.d(
"STATE DUMP",
"Addressables: " +
LocalCache.addressables.filter { _, it -> it.liveSet != null }.size +
" / " +
LocalCache.addressables.filter { _, it -> it.flowSet != null }.size +
" / " +
LocalCache.addressables.filter { _, it -> it.event != null }.size +
" / " +
LocalCache.addressables.size(),
)
Log.d(
"STATE DUMP",
"Users: " +
LocalCache.users.filter { _, it -> it.liveSet != null }.size +
" / " +
LocalCache.users.filter { _, it -> it.flowSet != null }.size +
" / " +
LocalCache.users.filter { _, it -> it.latestMetadata != null }.size +
" / " +
LocalCache.users.size(),
)
Log.d(
"STATE DUMP",
"Deletion Events: " +
LocalCache.deletionIndex.size(),
)
Log.d(
"STATE DUMP",
"Observable Events: " +
LocalCache.observablesByKindAndETag.size +
" / " +
LocalCache.observablesByKindAndAuthor.size,
)
Log.d(
"STATE DUMP",
"Spam: " +
LocalCache.antiSpam.spamMessages.size() + " / " + LocalCache.antiSpam.recentMessages.size(),
)
Log.d(
"STATE DUMP",
"Memory used by Events: " +
LocalCache.notes.sumOfLong { _, note -> note.event?.countMemory() ?: 0L } / (1024 * 1024) +
" MB",
)
val qttNotes = LocalCache.notes.countByGroup { _, it -> it.event?.kind() }
val qttAddressables = LocalCache.addressables.countByGroup { _, it -> it.event?.kind() }
val bytesNotes =
LocalCache.notes
.sumByGroup(groupMap = { _, it -> it.event?.kind() }, sumOf = { _, it -> it.event?.countMemory() ?: 0L })
val bytesAddressables =
LocalCache.addressables
.sumByGroup(groupMap = { _, it -> it.event?.kind() }, sumOf = { _, it -> it.event?.countMemory() ?: 0L })
qttNotes.toList().sortedByDescending { bytesNotes.get(it.first) }.forEach { (kind, qtt) ->
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesNotes.get(kind)?.div((1024 * 1024))}MB ")
}
qttAddressables.toList().sortedByDescending { bytesNotes.get(it.first) }.forEach { (kind, qtt) ->
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesAddressables.get(kind)?.div((1024 * 1024))}MB ")
}
}

View File

@@ -34,8 +34,10 @@ import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.amethyst.ui.tor.TorType
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
@@ -55,6 +57,7 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -72,6 +75,7 @@ data class AccountInfo(
val npub: String,
val hasPrivKey: Boolean,
val loggedInWithExternalSigner: Boolean,
val isTransient: Boolean,
)
private object PrefKeys {
@@ -103,6 +107,7 @@ private object PrefKeys {
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
const val TOR_SETTINGS = "tor_settings"
const val USE_PROXY = "use_proxy"
const val PROXY_PORT = "proxy_port"
const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
@@ -122,7 +127,7 @@ object LocalPreferences {
private const val COMMA = ","
private var currentAccount: String? = null
private var savedAccounts: List<AccountInfo>? = null
private var savedAccounts: MutableStateFlow<List<AccountInfo>?> = MutableStateFlow(null)
private var cachedAccounts: MutableMap<String, AccountSettings?> = mutableMapOf()
suspend fun currentAccount(): String? {
@@ -135,22 +140,24 @@ object LocalPreferences {
return currentAccount
}
private suspend fun updateCurrentAccount(npub: String?) {
if (npub == null) {
private suspend fun updateCurrentAccount(info: AccountInfo?) {
if (info == null) {
currentAccount = null
withContext(Dispatchers.IO) {
encryptedPreferences().edit().clear().apply()
}
} else if (currentAccount != npub) {
currentAccount = npub
withContext(Dispatchers.IO) {
encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, npub) }.apply()
} else if (currentAccount != info.npub) {
currentAccount = info.npub
if (!info.isTransient) {
withContext(Dispatchers.IO) {
encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, info.npub) }.apply()
}
}
}
}
private suspend fun savedAccounts(): List<AccountInfo> {
if (savedAccounts == null) {
if (savedAccounts.value == null) {
withContext(Dispatchers.IO) {
with(encryptedPreferences()) {
val newSystemOfAccounts =
@@ -159,7 +166,7 @@ object LocalPreferences {
}
if (!newSystemOfAccounts.isNullOrEmpty()) {
savedAccounts = newSystemOfAccounts
savedAccounts.emit(newSystemOfAccounts)
} else {
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
@@ -168,32 +175,37 @@ object LocalPreferences {
AccountInfo(
npub,
encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false),
(encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "")
.isNotBlank(),
(encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank(),
false,
)
}
savedAccounts = migrated
savedAccounts.emit(migrated)
edit().apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(savedAccounts)) }.apply()
edit().apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(savedAccounts.value)) }.apply()
}
}
}
}
return savedAccounts!!
// it's always not null when it gets here.
return savedAccounts.value!!
}
fun cachedAccounts() = savedAccounts
fun accountsFlow() = savedAccounts
private suspend fun updateSavedAccounts(accounts: List<AccountInfo>) =
withContext(Dispatchers.IO) {
if (savedAccounts != accounts) {
savedAccounts = accounts
savedAccounts.emit(accounts)
encryptedPreferences()
.edit()
.apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(accounts)) }
.apply()
.apply {
putString(
PrefKeys.ALL_ACCOUNT_INFO,
Event.mapper.writeValueAsString(accounts.filter { !it.isTransient }),
)
}.apply()
}
}
@@ -212,12 +224,13 @@ object LocalPreferences {
npub,
accountSettings.isWriteable(),
accountSettings.externalSignerPackageName != null,
accountSettings.transientAccount,
)
updateCurrentAccount(npub)
updateCurrentAccount(accInfo)
addAccount(accInfo)
}
suspend fun switchToAccount(accountInfo: AccountInfo) = updateCurrentAccount(accountInfo.npub)
suspend fun switchToAccount(accountInfo: AccountInfo) = updateCurrentAccount(accountInfo)
/** Removes the account from the app level shared preferences */
private suspend fun removeAccount(accountInfo: AccountInfo) {
@@ -258,7 +271,7 @@ object LocalPreferences {
*/
@SuppressLint("ApplySharedPref")
suspend fun updatePrefsForLogout(accountInfo: AccountInfo) {
Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout")
Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout ${accountInfo.npub}")
withContext(Dispatchers.IO) {
encryptedPreferences(accountInfo.npub).edit().clear().commit()
removeAccount(accountInfo)
@@ -267,7 +280,7 @@ object LocalPreferences {
if (savedAccounts().isEmpty()) {
updateCurrentAccount(null)
} else if (currentAccount() == accountInfo.npub) {
updateCurrentAccount(savedAccounts().elementAt(0).npub)
updateCurrentAccount(savedAccounts().elementAt(0))
}
}
}
@@ -281,145 +294,152 @@ object LocalPreferences {
suspend fun saveToEncryptedStorage(settings: AccountSettings) {
Log.d("LocalPreferences", "Saving to encrypted storage")
withContext(Dispatchers.IO) {
val prefs = encryptedPreferences(settings.keyPair.pubKey.toNpub())
prefs
.edit()
.apply {
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, settings.externalSignerPackageName != null)
if (settings.externalSignerPackageName != null) {
remove(PrefKeys.NOSTR_PRIVKEY)
putString(PrefKeys.SIGNER_PACKAGE_NAME, settings.externalSignerPackageName)
} else {
remove(PrefKeys.SIGNER_PACKAGE_NAME)
settings.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
}
settings.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(settings.localRelays))
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, settings.dontTranslateFrom)
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers)
putString(
PrefKeys.LANGUAGE_PREFS,
Event.mapper.writeValueAsString(settings.languagePreferences),
)
putString(PrefKeys.TRANSLATE_TO, settings.translateTo)
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(settings.zapAmountChoices.value))
putString(
PrefKeys.REACTION_CHOICES,
Event.mapper.writeValueAsString(settings.reactionChoices.value),
)
putString(PrefKeys.DEFAULT_ZAPTYPE, settings.defaultZapType.value.name)
putString(
PrefKeys.DEFAULT_FILE_SERVER,
Event.mapper.writeValueAsString(settings.defaultFileServer),
)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, settings.defaultHomeFollowList.value)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, settings.defaultStoriesFollowList.value)
putString(
PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST,
settings.defaultNotificationFollowList.value,
)
putString(
PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST,
settings.defaultDiscoveryFollowList.value,
)
putString(
PrefKeys.ZAP_PAYMENT_REQUEST_SERVER,
Event.mapper.writeValueAsString(settings.zapPaymentRequest),
)
if (settings.backupContactList != null) {
if (!settings.transientAccount) {
withContext(Dispatchers.IO) {
val prefs = encryptedPreferences(settings.keyPair.pubKey.toNpub())
prefs
.edit()
.apply {
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, settings.externalSignerPackageName != null)
if (settings.externalSignerPackageName != null) {
remove(PrefKeys.NOSTR_PRIVKEY)
putString(PrefKeys.SIGNER_PACKAGE_NAME, settings.externalSignerPackageName)
} else {
remove(PrefKeys.SIGNER_PACKAGE_NAME)
settings.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
}
settings.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(settings.localRelays))
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, settings.dontTranslateFrom)
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers)
putString(
PrefKeys.LATEST_CONTACT_LIST,
Event.mapper.writeValueAsString(settings.backupContactList),
PrefKeys.LANGUAGE_PREFS,
Event.mapper.writeValueAsString(settings.languagePreferences),
)
} else {
remove(PrefKeys.LATEST_CONTACT_LIST)
}
if (settings.backupUserMetadata != null) {
putString(PrefKeys.TRANSLATE_TO, settings.translateTo)
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(settings.zapAmountChoices.value))
putString(
PrefKeys.LATEST_USER_METADATA,
Event.mapper.writeValueAsString(settings.backupUserMetadata),
PrefKeys.REACTION_CHOICES,
Event.mapper.writeValueAsString(settings.reactionChoices.value),
)
} else {
remove(PrefKeys.LATEST_USER_METADATA)
}
if (settings.backupDMRelayList != null) {
putString(PrefKeys.DEFAULT_ZAPTYPE, settings.defaultZapType.value.name)
putString(
PrefKeys.LATEST_DM_RELAY_LIST,
Event.mapper.writeValueAsString(settings.backupDMRelayList),
PrefKeys.DEFAULT_FILE_SERVER,
Event.mapper.writeValueAsString(settings.defaultFileServer),
)
} else {
remove(PrefKeys.LATEST_DM_RELAY_LIST)
}
if (settings.backupNIP65RelayList != null) {
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, settings.defaultHomeFollowList.value)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, settings.defaultStoriesFollowList.value)
putString(
PrefKeys.LATEST_NIP65_RELAY_LIST,
Event.mapper.writeValueAsString(settings.backupNIP65RelayList),
PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST,
settings.defaultNotificationFollowList.value,
)
} else {
remove(PrefKeys.LATEST_NIP65_RELAY_LIST)
}
if (settings.backupSearchRelayList != null) {
putString(
PrefKeys.LATEST_SEARCH_RELAY_LIST,
Event.mapper.writeValueAsString(settings.backupSearchRelayList),
PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST,
settings.defaultDiscoveryFollowList.value,
)
} else {
remove(PrefKeys.LATEST_SEARCH_RELAY_LIST)
}
if (settings.backupMuteList != null) {
putString(
PrefKeys.LATEST_MUTE_LIST,
Event.mapper.writeValueAsString(settings.backupMuteList),
PrefKeys.ZAP_PAYMENT_REQUEST_SERVER,
Event.mapper.writeValueAsString(settings.zapPaymentRequest),
)
} else {
remove(PrefKeys.LATEST_MUTE_LIST)
}
if (settings.backupPrivateHomeRelayList != null) {
putString(
PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST,
Event.mapper.writeValueAsString(settings.backupPrivateHomeRelayList),
)
} else {
remove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
}
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
putBoolean(PrefKeys.USE_PROXY, settings.proxy != null)
putInt(PrefKeys.PROXY_PORT, settings.proxyPort)
putBoolean(PrefKeys.WARN_ABOUT_REPORTS, settings.warnAboutPostsWithReports)
putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, settings.filterSpamFromStrangers)
val regularMap =
settings.lastReadPerRoute.value.mapValues {
it.value.value
if (settings.backupContactList != null) {
putString(
PrefKeys.LATEST_CONTACT_LIST,
Event.mapper.writeValueAsString(settings.backupContactList),
)
} else {
remove(PrefKeys.LATEST_CONTACT_LIST)
}
putString(
PrefKeys.LAST_READ_PER_ROUTE,
Event.mapper.writeValueAsString(regularMap),
)
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value)
if (settings.backupUserMetadata != null) {
putString(
PrefKeys.LATEST_USER_METADATA,
Event.mapper.writeValueAsString(settings.backupUserMetadata),
)
} else {
remove(PrefKeys.LATEST_USER_METADATA)
}
if (settings.showSensitiveContent.value == null) {
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
} else {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, settings.showSensitiveContent.value!!)
}
if (settings.backupDMRelayList != null) {
putString(
PrefKeys.LATEST_DM_RELAY_LIST,
Event.mapper.writeValueAsString(settings.backupDMRelayList),
)
} else {
remove(PrefKeys.LATEST_DM_RELAY_LIST)
}
putString(
PrefKeys.PENDING_ATTESTATIONS,
Event.mapper.writeValueAsString(settings.pendingAttestations.value),
)
}.apply()
if (settings.backupNIP65RelayList != null) {
putString(
PrefKeys.LATEST_NIP65_RELAY_LIST,
Event.mapper.writeValueAsString(settings.backupNIP65RelayList),
)
} else {
remove(PrefKeys.LATEST_NIP65_RELAY_LIST)
}
if (settings.backupSearchRelayList != null) {
putString(
PrefKeys.LATEST_SEARCH_RELAY_LIST,
Event.mapper.writeValueAsString(settings.backupSearchRelayList),
)
} else {
remove(PrefKeys.LATEST_SEARCH_RELAY_LIST)
}
if (settings.backupMuteList != null) {
putString(
PrefKeys.LATEST_MUTE_LIST,
Event.mapper.writeValueAsString(settings.backupMuteList),
)
} else {
remove(PrefKeys.LATEST_MUTE_LIST)
}
if (settings.backupPrivateHomeRelayList != null) {
putString(
PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST,
Event.mapper.writeValueAsString(settings.backupPrivateHomeRelayList),
)
} else {
remove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
}
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
// migrating from previous design
remove(PrefKeys.USE_PROXY)
remove(PrefKeys.PROXY_PORT)
putString(PrefKeys.TOR_SETTINGS, Event.mapper.writeValueAsString(settings.torSettings.toSettings()))
putBoolean(PrefKeys.WARN_ABOUT_REPORTS, settings.warnAboutPostsWithReports)
putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, settings.filterSpamFromStrangers)
val regularMap =
settings.lastReadPerRoute.value.mapValues {
it.value.value
}
putString(
PrefKeys.LAST_READ_PER_ROUTE,
Event.mapper.writeValueAsString(regularMap),
)
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value)
if (settings.showSensitiveContent.value == null) {
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
} else {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, settings.showSensitiveContent.value!!)
}
putString(
PrefKeys.PENDING_ATTESTATIONS,
Event.mapper.writeValueAsString(settings.pendingAttestations.value),
)
}.apply()
}
}
}
@@ -528,8 +548,28 @@ object LocalPreferences {
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
val proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort)
val torSettings =
if (useProxy) {
// old settings, means Orbot
TorSettings(
TorType.EXTERNAL,
getInt(PrefKeys.PROXY_PORT, 9050),
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
)
} else {
parseOrNull<TorSettings>(PrefKeys.TOR_SETTINGS) ?: TorSettings()
}
val showSensitiveContent =
if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
@@ -550,6 +590,7 @@ object LocalPreferences {
return@with AccountSettings(
keyPair = keyPair,
transientAccount = false,
externalSignerPackageName = externalSignerPackageName,
localRelays = localRelays,
localRelayServers = localRelayServers,
@@ -575,8 +616,7 @@ object LocalPreferences {
backupSearchRelayList = latestSearchRelayList,
backupPrivateHomeRelayList = latestPrivateHomeRelayList,
backupMuteList = latestMuteList,
proxy = proxy,
proxyPort = proxyPort,
torSettings = TorSettingsFlow.build(torSettings),
showSensitiveContent = MutableStateFlow(showSensitiveContent),
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,

View File

@@ -48,11 +48,17 @@ import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.amethyst.ui.tor.TorType
import com.vitorpamplona.ammolite.relays.Client
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.encoders.bechToBytes
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.ots.OpenTimestamps
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -88,8 +94,27 @@ class ServiceManager(
val myAccount = account
// Resets Proxy Use
HttpClientManager.setDefaultProxy(account?.settings?.proxy)
HttpClientManager.setDefaultUserAgent("Amethyst/${BuildConfig.VERSION_NAME}")
if (myAccount != null) {
when (myAccount.settings.torSettings.torType.value) {
TorType.INTERNAL -> {
Log.d("TorManager", "Relays Service Connected ${TorManager.socksPort()}")
HttpClientManager.setDefaultProxyOnPort(TorManager.socksPort())
}
TorType.EXTERNAL -> HttpClientManager.setDefaultProxyOnPort(myAccount.settings.torSettings.externalSocksPort.value)
else -> HttpClientManager.setDefaultProxy(null)
}
OtsEvent.otsInstance =
OpenTimestamps(
OkHttpBlockstreamExplorer(myAccount::shouldUseTorForMoneyOperations),
OkHttpCalendarBuilder(myAccount::shouldUseTorForMoneyOperations),
)
} else {
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer { false }, OkHttpCalendarBuilder { false })
HttpClientManager.setDefaultProxy(null)
}
LocalCache.antiSpam.active = account?.settings?.filterSpamFromStrangers ?: true
Coil.setImageLoader {
Amethyst.instance
@@ -106,22 +131,23 @@ class ServiceManager(
if (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark") {
this.logger(DebugLogger())
}
}.okHttpClient { HttpClientManager.getHttpClient() }
.precision(Precision.INEXACT)
}.okHttpClient {
myAccount?.shouldUseTorForImageDownload()?.let { HttpClientManager.getHttpClient(it) }
?: HttpClientManager.getHttpClient(false)
}.precision(Precision.INEXACT)
.respectCacheHeaders(false)
.build()
}
if (myAccount != null) {
val relaySet = myAccount.connectToRelays.value
Log.d("Relay", "Service Manager Connect Connecting ${relaySet.size}")
val relaySet = myAccount.connectToRelaysWithProxy.value
Client.reconnect(relaySet)
collectorJob?.cancel()
collectorJob = null
collectorJob =
scope.launch {
myAccount.connectToRelaysFlow.collectLatest {
myAccount.connectToRelaysWithProxy.collectLatest {
delay(500)
if (isStarted) {
Client.reconnect(it, onlyIfChanged = true)

View File

@@ -31,13 +31,16 @@ import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.tor.TorType
import com.vitorpamplona.ammolite.relays.Client
import com.vitorpamplona.ammolite.relays.Constants
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.ammolite.relays.RelaySetupInfoToConnect
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
@@ -115,6 +118,7 @@ import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
@@ -405,6 +409,51 @@ class Account(
).toTypedArray(),
)
val connectToRelaysWithProxy =
combineTransform(
connectToRelays,
settings.torSettings.torType,
settings.torSettings.onionRelaysViaTor,
settings.torSettings.trustedRelaysViaTor,
) { relays, torType, useTorForOnionRelays, useTorForTrustedRelays ->
emit(
relays
.map {
RelaySetupInfoToConnect(
it.url,
torType != TorType.OFF && checkLocalHostOnionAndThen(it.url, useTorForOnionRelays, useTorForTrustedRelays),
it.read,
it.write,
it.feedTypes,
)
}.toTypedArray(),
)
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
normalizeAndCombineRelayListsWithFallbacks(
kind3Relays(),
getDMRelayList(),
getSearchRelayList(),
getPrivateOutboxRelayList(),
getNIP65RelayList(),
).map {
RelaySetupInfoToConnect(
it.url,
settings.torSettings.torType.value != TorType.OFF &&
checkLocalHostOnionAndThen(
it.url,
settings.torSettings.onionRelaysViaTor.value,
settings.torSettings.trustedRelaysViaTor.value,
),
it.read,
it.write,
it.feedTypes,
)
}.toTypedArray(),
)
fun buildFollowLists(latestContactList: ContactListEvent?): LiveFollowLists {
// makes sure the output include only valid p tags
val verifiedFollowingUsers = latestContactList?.verifiedFollowKeySet() ?: emptySet()
@@ -425,6 +474,36 @@ class Account(
)
}
fun normalizeDMRelayListWithBackup(note: Note): Set<String> {
val event = note.event as? ChatMessageRelayListEvent ?: settings.backupDMRelayList
return event?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet()
}
val normalizedDmRelaySet =
getDMRelayListFlow()
.map { normalizeDMRelayListWithBackup(it.note) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
normalizeDMRelayListWithBackup(getDMRelayListNote()),
)
fun normalizePrivateOutboxRelayListWithBackup(note: Note): Set<String> {
val event = note.event as? PrivateOutboxRelayListEvent ?: settings.backupPrivateHomeRelayList
return event?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet()
}
val normalizedPrivateOutBoxRelaySet =
getPrivateOutboxRelayListFlow()
.map { normalizePrivateOutboxRelayListWithBackup(it.note) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
normalizePrivateOutboxRelayListWithBackup(getPrivateOutboxRelayListNote()),
)
@OptIn(ExperimentalCoroutinesApi::class)
val liveKind3FollowsFlow: Flow<LiveFollowLists> =
userProfile().flow().follows.stateFlow.transformLatest {
@@ -562,6 +641,13 @@ class Account(
fun authorsPerRelay(
followsNIP65RelayLists: List<Note>,
defaultRelayList: List<String>,
torType: TorType,
): Map<String, List<HexKey>> = authorsPerRelay(followsNIP65RelayLists, defaultRelayList, torType != TorType.OFF)
fun authorsPerRelay(
followsNIP65RelayLists: List<Note>,
defaultRelayList: List<String>,
acceptOnion: Boolean,
): Map<String, List<HexKey>> {
checkNotInMainThread()
@@ -595,7 +681,7 @@ class Account(
}
}
}.toMap(),
hasOnionConnection = settings.proxy != null,
hasOnionConnection = acceptOnion,
)
}
@@ -611,12 +697,13 @@ class Account(
}
val liveHomeListAuthorsPerRelayFlow: Flow<Map<String, List<HexKey>>?> by lazy {
combineTransform(liveHomeFollowListAdvertizedRelayListFlow, connectToRelays) { adverisedRelayList, existing ->
combineTransform(liveHomeFollowListAdvertizedRelayListFlow, connectToRelays, settings.torSettings.torType) { adverisedRelayList, existing, torStatus ->
if (adverisedRelayList != null) {
emit(
authorsPerRelay(
adverisedRelayList.map { it.note },
existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url },
torStatus,
),
)
} else {
@@ -632,7 +719,8 @@ class Account(
authorsPerRelay(
liveHomeFollowLists.value?.usersPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(),
connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url },
),
settings.torSettings.torType.value,
).ifEmpty { null },
)
}
@@ -692,9 +780,15 @@ class Account(
}
val liveStoriesListAuthorsPerRelayFlow: Flow<Map<String, List<String>>?> by lazy {
combineTransform(liveStoriesFollowListAdvertizedRelayListFlow, connectToRelays) { adverisedRelayList, existing ->
combineTransform(liveStoriesFollowListAdvertizedRelayListFlow, connectToRelays, settings.torSettings.torType) { adverisedRelayList, existing, torState ->
if (adverisedRelayList != null) {
emit(authorsPerRelay(adverisedRelayList.map { it.note }, existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }))
emit(
authorsPerRelay(
adverisedRelayList.map { it.note },
existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url },
torState,
),
)
} else {
emit(null)
}
@@ -708,7 +802,8 @@ class Account(
authorsPerRelay(
liveStoriesFollowLists.value?.usersPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(),
connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url },
),
settings.torSettings.torType.value,
).ifEmpty { null },
)
}
@@ -746,9 +841,15 @@ class Account(
}
val liveDiscoveryListAuthorsPerRelayFlow: Flow<Map<String, List<String>>?> by lazy {
combineTransform(liveDiscoveryFollowListAdvertizedRelayListFlow, connectToRelays) { adverisedRelayList, existing ->
combineTransform(liveDiscoveryFollowListAdvertizedRelayListFlow, connectToRelays, settings.torSettings.torType) { adverisedRelayList, existing, torState ->
if (adverisedRelayList != null) {
emit(authorsPerRelay(adverisedRelayList.map { it.note }, existing.filter { it.read }.map { it.url }))
emit(
authorsPerRelay(
adverisedRelayList.map { it.note },
existing.filter { it.read }.map { it.url },
torState,
),
)
} else {
emit(null)
}
@@ -761,8 +862,9 @@ class Account(
SharingStarted.Eagerly,
authorsPerRelay(
liveDiscoveryFollowLists.value?.usersPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(),
connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url },
),
connectToRelays.value.filter { it.read }.map { it.url },
settings.torSettings.torType.value,
).ifEmpty { null },
)
}
@@ -1192,7 +1294,18 @@ class Account(
LocalCache.consume(event, zappedNote) { it.response(signer) { onResponse(it) } }
Client.send(event, nip47.relayUri, wcListener.feedTypes) { wcListener.destroy() }
Client.sendSingle(
signedEvent = event,
relayTemplate =
RelaySetupInfoToConnect(
url = nip47.relayUri,
forceProxy = shouldUseTorForTrustedRelays(), // this is trusted.
read = true,
write = true,
feedTypes = wcListener.feedTypes,
),
onDone = { wcListener.destroy() },
)
onSent()
}
@@ -1640,7 +1753,7 @@ class Account(
fun consumeAndSendNip95(
data: FileStorageEvent,
signedEvent: FileStorageHeaderEvent,
relayList: List<RelaySetupInfo>? = null,
relayList: List<RelaySetupInfo>,
): Note? {
if (!isWriteable()) return null
@@ -1666,7 +1779,7 @@ class Account(
fun sendNip95(
data: FileStorageEvent,
signedEvent: FileStorageHeaderEvent,
relayList: List<RelaySetupInfo>? = null,
relayList: List<RelaySetupInfo>,
) {
Client.send(data, relayList = relayList)
Client.send(signedEvent, relayList = relayList)
@@ -1674,7 +1787,7 @@ class Account(
fun sendHeader(
signedEvent: FileHeaderEvent,
relayList: List<RelaySetupInfo>? = null,
relayList: List<RelaySetupInfo>,
onReady: (Note) -> Unit,
) {
Client.send(signedEvent, relayList = relayList)
@@ -1718,7 +1831,7 @@ class Account(
alt: String?,
sensitiveContent: Boolean,
originalHash: String? = null,
relayList: List<RelaySetupInfo>? = null,
relayList: List<RelaySetupInfo>,
onReady: (Note) -> Unit,
) {
if (!isWriteable()) return
@@ -1753,7 +1866,7 @@ class Account(
zapReceiver: List<ZapSplitSetup>? = null,
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
relayList: List<RelaySetupInfo>? = null,
relayList: List<RelaySetupInfo>,
geohash: String? = null,
nip94attachments: List<Event>? = null,
draftTag: String?,
@@ -1791,13 +1904,7 @@ class Account(
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent ->
val newRelayList = getPrivateOutboxRelayList()?.relays()
if (newRelayList != null) {
Client.sendPrivately(draftEvent, newRelayList)
} else {
Client.send(draftEvent, relayList = relayList)
}
LocalCache.justConsume(draftEvent, null)
sendDraftEvent(draftEvent)
}
}
} else {
@@ -1826,7 +1933,7 @@ class Account(
root: String?,
directMentions: Set<HexKey>,
forkedFrom: Event?,
relayList: List<RelaySetupInfo>? = null,
relayList: List<RelaySetupInfo>,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
@@ -1860,13 +1967,7 @@ class Account(
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
val newRelayList = getPrivateOutboxRelayList()?.relays()
if (newRelayList != null) {
Client.sendPrivately(draftEvent, newRelayList)
} else {
Client.send(draftEvent, relayList = relayList)
}
LocalCache.justConsume(draftEvent, null)
sendDraftEvent(draftEvent)
}
}
} else {
@@ -1900,7 +2001,7 @@ class Account(
root: String,
directMentions: Set<HexKey>,
forkedFrom: Event?,
relayList: List<RelaySetupInfo>? = null,
relayList: List<RelaySetupInfo>,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
@@ -1932,13 +2033,7 @@ class Account(
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
val newRelayList = getPrivateOutboxRelayList()?.relays()
if (newRelayList != null) {
Client.sendPrivately(draftEvent, newRelayList)
} else {
Client.send(draftEvent, relayList = relayList)
}
LocalCache.justConsume(draftEvent, null)
sendDraftEvent(draftEvent)
}
}
} else {
@@ -1967,7 +2062,18 @@ class Account(
val noteEvent = note.event
if (noteEvent is DraftEvent) {
noteEvent.createDeletedEvent(signer) {
Client.sendPrivately(it, relayList = note.relays.map { it.url })
Client.sendPrivately(
it,
note.relays.map { it.url }.map {
RelaySetupInfoToConnect(
it,
shouldUseTorForClean(it),
false,
true,
emptySet(),
)
},
)
LocalCache.justConsume(it, null)
}
}
@@ -1987,7 +2093,7 @@ class Account(
root: String?,
directMentions: Set<HexKey>,
forkedFrom: Event?,
relayList: List<RelaySetupInfo>? = null,
relayList: List<RelaySetupInfo>,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
@@ -2021,13 +2127,7 @@ class Account(
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
val newRelayList = getPrivateOutboxRelayList()?.relays()
if (newRelayList != null) {
Client.sendPrivately(draftEvent, newRelayList)
} else {
Client.send(draftEvent, relayList = relayList)
}
LocalCache.justConsume(draftEvent, null)
sendDraftEvent(draftEvent)
}
}
} else {
@@ -2055,7 +2155,7 @@ class Account(
originalNote: Note,
notify: HexKey?,
summary: String? = null,
relayList: List<RelaySetupInfo>? = null,
relayList: List<RelaySetupInfo>,
) {
if (!isWriteable()) return
@@ -2085,7 +2185,7 @@ class Account(
zapReceiver: List<ZapSplitSetup>? = null,
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
relayList: List<RelaySetupInfo>? = null,
relayList: List<RelaySetupInfo>,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
@@ -2119,13 +2219,7 @@ class Account(
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
val newRelayList = getPrivateOutboxRelayList()?.relays()
if (newRelayList != null) {
Client.sendPrivately(draftEvent, newRelayList)
} else {
Client.send(draftEvent, relayList = relayList)
}
LocalCache.justConsume(draftEvent, null)
sendDraftEvent(draftEvent)
}
}
} else {
@@ -2178,13 +2272,7 @@ class Account(
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
val newRelayList = getPrivateOutboxRelayList()?.relays()
if (newRelayList != null) {
Client.sendPrivately(draftEvent, newRelayList)
} else {
Client.send(draftEvent)
}
LocalCache.justConsume(draftEvent, null)
sendDraftEvent(draftEvent)
}
}
} else {
@@ -2230,13 +2318,7 @@ class Account(
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
val newRelayList = getPrivateOutboxRelayList()?.relays()
if (newRelayList != null) {
Client.sendPrivately(draftEvent, newRelayList)
} else {
Client.send(draftEvent)
}
LocalCache.justConsume(draftEvent, null)
sendDraftEvent(draftEvent)
}
}
} else {
@@ -2309,13 +2391,7 @@ class Account(
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent ->
val newRelayList = getPrivateOutboxRelayList()?.relays()
if (newRelayList != null) {
Client.sendPrivately(draftEvent, newRelayList)
} else {
Client.send(draftEvent)
}
LocalCache.justConsume(draftEvent, null)
sendDraftEvent(draftEvent)
}
}
} else {
@@ -2362,13 +2438,7 @@ class Account(
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it.msg, emptyList(), signer) { draftEvent ->
val newRelayList = getPrivateOutboxRelayList()?.relays()
if (newRelayList != null) {
Client.sendPrivately(draftEvent, newRelayList)
} else {
Client.send(draftEvent)
}
LocalCache.justConsume(draftEvent, null)
sendDraftEvent(draftEvent)
}
}
} else {
@@ -2377,6 +2447,26 @@ class Account(
}
}
fun sendDraftEvent(draftEvent: DraftEvent) {
val relayList =
normalizedPrivateOutBoxRelaySet.value.map {
RelaySetupInfoToConnect(
it,
shouldUseTorForClean(it),
true,
true,
emptySet(),
)
}
if (relayList.isNotEmpty()) {
Client.sendPrivately(draftEvent, relayList)
} else {
Client.send(draftEvent)
}
LocalCache.justConsume(draftEvent, null)
}
fun broadcastPrivately(signedEvents: NIP17Factory.Result) {
val mine = signedEvents.wraps.filter { (it.recipientPubKey() == signer.pubKey) }
@@ -2410,7 +2500,16 @@ class Account(
LocalCache
.getAddressableNoteIfExists(ChatMessageRelayListEvent.createAddressTag(receiver))
?.event as? ChatMessageRelayListEvent
)?.relays()?.ifEmpty { null }
)?.relays()?.ifEmpty { null }?.map {
val normalizedUrl = RelayUrlFormatter.normalize(it)
RelaySetupInfoToConnect(
normalizedUrl,
shouldUseTorForClean(normalizedUrl),
false,
true,
feedTypes = setOf(FeedType.PRIVATE_DMS),
)
}
if (relayList != null) {
Client.sendPrivately(signedEvent = wrap, relayList = relayList)
@@ -2863,7 +2962,22 @@ class Account(
onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit,
) {
NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey, signer.pubKey, getReceivingRelays(), signer) {
val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(dvmPublicKey))?.event as? AdvertisedRelayListEvent)?.readRelays()
val relayList =
(
LocalCache
.getAddressableNoteIfExists(
AdvertisedRelayListEvent.createAddressTag(dvmPublicKey),
)?.event as? AdvertisedRelayListEvent
)?.readRelays()?.ifEmpty { null }?.map {
val normalizedUrl = RelayUrlFormatter.normalize(it)
RelaySetupInfoToConnect(
normalizedUrl,
shouldUseTorForClean(normalizedUrl),
true,
true,
setOf(FeedType.GLOBAL),
)
}
if (relayList != null) {
Client.sendPrivately(it, relayList)
@@ -3262,6 +3376,116 @@ class Account(
fun markDonatedInThisVersion() = settings.markDonatedInThisVersion(BuildConfig.VERSION_NAME)
fun httpClientForCoil() = HttpClientManager.getHttpClient(shouldUseTorForImageDownload())
fun httpClientForRelay(dirtyUrl: String) = HttpClientManager.getHttpClient(shouldUseTorForDirty(dirtyUrl))
fun httpClientForPreviewUrl(url: String) = HttpClientManager.getHttpClient(shouldUseTorForPreviewUrl(url))
fun shouldUseTorForImageDownload() =
when (settings.torSettings.torType.value) {
TorType.OFF -> false
TorType.INTERNAL -> settings.torSettings.imagesViaTor.value
TorType.EXTERNAL -> settings.torSettings.imagesViaTor.value
}
fun shouldUseTorForVideoDownload() =
when (settings.torSettings.torType.value) {
TorType.OFF -> false
TorType.INTERNAL -> settings.torSettings.videosViaTor.value
TorType.EXTERNAL -> settings.torSettings.videosViaTor.value
}
fun shouldUseTorForVideoDownload(url: String) =
when (settings.torSettings.torType.value) {
TorType.OFF -> false
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.videosViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.videosViaTor.value)
}
fun shouldUseTorForPreviewUrl(url: String) =
when (settings.torSettings.torType.value) {
TorType.OFF -> false
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.urlPreviewsViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.urlPreviewsViaTor.value)
}
fun shouldUseTorForTrustedRelays() =
when (settings.torSettings.torType.value) {
TorType.OFF -> false
TorType.INTERNAL -> settings.torSettings.trustedRelaysViaTor.value
TorType.EXTERNAL -> settings.torSettings.trustedRelaysViaTor.value
}
fun shouldUseTorForDirty(dirtyUrl: String) = shouldUseTorForClean(RelayUrlFormatter.normalize(dirtyUrl))
fun shouldUseTorForClean(normalizedUrl: String) =
when (settings.torSettings.torType.value) {
TorType.OFF -> false
TorType.INTERNAL -> shouldUseTor(normalizedUrl)
TorType.EXTERNAL -> shouldUseTor(normalizedUrl)
}
private fun checkLocalHostOnionAndThen(
normalizedUrl: String,
final: Boolean,
): Boolean = checkLocalHostOnionAndThen(normalizedUrl, settings.torSettings.onionRelaysViaTor.value, final)
private fun checkLocalHostOnionAndThen(
normalizedUrl: String,
isOnionRelaysActive: Boolean,
final: Boolean,
): Boolean =
if (isLocalHost(normalizedUrl)) {
false
} else if (isOnionUrl(normalizedUrl)) {
isOnionRelaysActive
} else {
final
}
private fun shouldUseTor(normalizedUrl: String): Boolean =
if (isLocalHost(normalizedUrl)) {
false
} else if (isOnionUrl(normalizedUrl)) {
settings.torSettings.onionRelaysViaTor.value
} else if (isDMRelay(normalizedUrl)) {
settings.torSettings.dmRelaysViaTor.value
} else if (isTrustedRelay(normalizedUrl)) {
settings.torSettings.trustedRelaysViaTor.value
} else {
settings.torSettings.newRelaysViaTor.value
}
fun shouldUseTorForMoneyOperations(url: String) =
when (settings.torSettings.torType.value) {
TorType.OFF -> false
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.moneyOperationsViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.moneyOperationsViaTor.value)
}
fun shouldUseTorForNIP05(url: String) =
when (settings.torSettings.torType.value) {
TorType.OFF -> false
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip05VerificationsViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip05VerificationsViaTor.value)
}
fun shouldUseTorForNIP96(url: String) =
when (settings.torSettings.torType.value) {
TorType.OFF -> false
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip96UploadsViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip96UploadsViaTor.value)
}
fun isLocalHost(url: String) = url.contains("//127.0.0.1") || url.contains("//localhost")
fun isOnionUrl(url: String) = url.contains(".onion")
fun isDMRelay(url: String) = url in normalizedDmRelaySet.value
fun isTrustedRelay(url: String): Boolean = connectToRelays.value.any { it.url == url } || url == settings.zapPaymentRequest?.relayUri
init {
Log.d("AccountRegisterObservers", "Init")
settings.backupContactList?.let {
@@ -3291,9 +3515,13 @@ class Account(
GlobalScope.launch(Dispatchers.IO) { LocalCache.verifyAndConsume(it, null) }
}
settings.backupPrivateHomeRelayList?.let {
Log.d("AccountRegisterObservers", "Loading saved search relay list ${it.toJson()}")
GlobalScope.launch(Dispatchers.IO) { LocalCache.verifyAndConsume(it, null) }
settings.backupPrivateHomeRelayList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved search relay list ${event.toJson()}")
GlobalScope.launch(Dispatchers.IO) {
event.privateTags(signer) {
LocalCache.verifyAndConsume(event, null)
}
}
}
settings.backupMuteList?.let {

View File

@@ -24,15 +24,15 @@ import android.content.res.Resources
import androidx.compose.runtime.Stable
import androidx.core.os.ConfigurationCompat
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.ammolite.relays.Constants
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.ContactListEvent
@@ -51,7 +51,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import java.net.Proxy
import java.util.Locale
val DefaultChannels =
@@ -114,6 +113,7 @@ val KIND3_FOLLOWS = " All Follows "
@Stable
class AccountSettings(
val keyPair: KeyPair,
val transientAccount: Boolean = false,
var externalSignerPackageName: String? = null,
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var localRelayServers: Set<String> = setOf(),
@@ -139,8 +139,7 @@ class AccountSettings(
var backupSearchRelayList: SearchRelayListEvent? = null,
var backupMuteList: MuteListEvent? = null,
var backupPrivateHomeRelayList: PrivateOutboxRelayListEvent? = null,
var proxy: Proxy? = null,
var proxyPort: Int = 9050,
val torSettings: TorSettingsFlow = TorSettingsFlow(),
val showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null),
var warnAboutPostsWithReports: Boolean = true,
var filterSpamFromStrangers: Boolean = true,
@@ -150,6 +149,10 @@ class AccountSettings(
) {
val saveable = MutableStateFlow(AccountSettingsUpdater(this))
class AccountSettingsUpdater(
val accountSettings: AccountSettings,
)
fun saveAccountSettings() {
saveable.update { AccountSettingsUpdater(this) }
}
@@ -162,7 +165,7 @@ class AccountSettings(
} else {
when (val packageName = externalSignerPackageName) {
null -> NostrSignerInternal(keyPair)
else -> NostrSignerExternal(keyPair.pubKey.toHexKey(), ExternalSignerLauncher(keyPair.pubKey.toNpub(), packageName))
else -> NostrSignerExternal(keyPair.pubKey.toHexKey(), ExternalSignerLauncher(keyPair.pubKey.toHexKey(), packageName))
}
}
@@ -244,28 +247,25 @@ class AccountSettings(
// ---
// proxy settings
// ---
fun isProxyEnabled() = proxy != null
fun updateProxy(
enabled: Boolean,
portNumber: String,
) {
val port = portNumber.toIntOrNull() ?: return
if (proxyPort != port || isProxyEnabled() != enabled) {
proxyPort = portNumber.toInt()
proxy = HttpClientManager.initProxy(enabled, "127.0.0.1", proxyPort)
fun setTorSettings(newTorSettings: TorSettings): Boolean {
if (torSettings.update(newTorSettings)) {
saveAccountSettings()
return true
} else {
return false
}
}
// ---
// language services
// ---
fun addDontTranslateFrom(languageCode: String) {
fun toggleDontTranslateFrom(languageCode: String) {
if (!dontTranslateFrom.contains(languageCode)) {
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
saveAccountSettings()
} else {
dontTranslateFrom = dontTranslateFrom.minus(languageCode)
saveAccountSettings()
}
}
@@ -287,6 +287,12 @@ class AccountSettings(
if (key !in languagePreferences) {
languagePreferences = languagePreferences + Pair(key, preference)
saveAccountSettings()
} else {
if (languagePreferences.get(key) == preference) {
languagePreferences = languagePreferences.minus(key)
} else {
languagePreferences = languagePreferences + Pair(key, preference)
}
}
}
@@ -518,7 +524,3 @@ class AccountSettings(
false
}
}
class AccountSettingsUpdater(
val accountSettings: AccountSettings,
)

View File

@@ -47,21 +47,20 @@ import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.ui.components.HashTag
import com.vitorpamplona.amethyst.ui.components.RenderRegular
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.events.EmptyTagList
@Preview
@Composable
fun RenderHashTagIcons() {
val nav: (String) -> Unit = {}
fun RenderHashTagIconsPreview() {
ThemeComparisonColumn {
RenderRegular(
"Testing rendering of hashtags: #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate",
EmptyTagList,
) { word, state ->
when (word) {
is HashTagSegment -> HashTag(word, nav)
is HashTagSegment -> HashTag(word, EmptyNav)
is RegularTextSegment -> Text(word.segmentText)
}
}

View File

@@ -116,6 +116,7 @@ import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
import com.vitorpamplona.quartz.events.TorrentCommentEvent
import com.vitorpamplona.quartz.events.TorrentEvent
import com.vitorpamplona.quartz.events.VerificationState
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
import com.vitorpamplona.quartz.events.VideoVerticalEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
@@ -1093,7 +1094,7 @@ object LocalCache {
if (version.event?.id() == event.id()) return
// makes sure the OTS has a valid certificate
if (event.cacheVerify() == null) return // no valid OTS
if (event.cacheVerify() is VerificationState.Error) return // no valid OTS
if (version.event == null) {
version.loadEvent(event, author, emptyList())
@@ -1876,7 +1877,7 @@ object LocalCache {
}
try {
val cachePath = File(Amethyst.instance.applicationContext.cacheDir, "NIP95")
val cachePath = Amethyst.instance.nip95cache()
cachePath.mkdirs()
val file = File(cachePath, event.id)
if (!file.exists()) {
@@ -2155,7 +2156,7 @@ object LocalCache {
notes.forEach { _, item ->
val noteEvent = item.event
if ((noteEvent is OtsEvent && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time))) {
noteEvent.verifiedTime?.let { stampedTime ->
(noteEvent.cacheVerify() as? VerificationState.Verified)?.verifiedTime?.let { stampedTime ->
if (minTime == null || stampedTime < (minTime ?: Long.MAX_VALUE)) {
minTime = stampedTime
}

View File

@@ -94,16 +94,31 @@ class VisionPrescription(
var dateWritten: String? = null,
var prescriber: Reference? = Reference(),
var lensSpecification: List<LensSpecification> = arrayListOf(),
) : Resource(resourceType, id)
) : Resource(resourceType, id) {
fun glasses() = lensSpecification.filter { it.product == "lens" }
fun contacts() = lensSpecification.filter { it.product == "contacts" }
fun glassesRightEyes() = lensSpecification.filter { it.product == "lens" && it.eye == "right" }
fun glassesLeftEyes() = lensSpecification.filter { it.product == "lens" && it.eye == "left" }
fun contactsRightEyes() = lensSpecification.filter { it.product == "contacts" && it.eye == "right" }
fun contactsLeftEyes() = lensSpecification.filter { it.product == "contacts" && it.eye == "left" }
}
@Stable
class LensSpecification(
var product: String? = null,
var eye: String? = null,
var sphere: Double? = null,
var cylinder: Double? = null,
var axis: Double? = null,
var pd: Double? = null,
var interAdd: Double? = null,
var add: Double? = null,
var prism: List<Prism> = arrayListOf(),
var prism: Prism? = null,
// contact lenses
var power: Double? = null,
var backCurve: Double? = null,

View File

@@ -67,7 +67,9 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import java.math.BigDecimal
@@ -764,8 +766,32 @@ open class Note(
}
}
if (thisEvent is BaseTextNoteEvent) {
if (accountChoices.hiddenWordsCase.isNotEmpty() && thisEvent.content.containsAny(accountChoices.hiddenWordsCase)) {
if (accountChoices.hiddenWordsCase.isNotEmpty()) {
if (thisEvent is BaseTextNoteEvent && thisEvent.content.containsAny(accountChoices.hiddenWordsCase)) {
return true
}
if (author?.toBestDisplayName()?.containsAny(accountChoices.hiddenWordsCase) == true) {
return true
}
if (author?.profilePicture()?.containsAny(accountChoices.hiddenWordsCase) == true) {
return true
}
if (author?.info?.banner?.containsAny(accountChoices.hiddenWordsCase) == true) {
return true
}
if (author?.info?.about?.containsAny(accountChoices.hiddenWordsCase) == true) {
return true
}
if (author?.info?.lud06?.containsAny(accountChoices.hiddenWordsCase) == true) {
return true
}
if (author?.info?.lud16?.containsAny(accountChoices.hiddenWordsCase) == true) {
return true
}
}
@@ -848,6 +874,15 @@ class NoteFlowSet(
val reports = NoteBundledRefresherFlow(u)
val relays = NoteBundledRefresherFlow(u)
@OptIn(ExperimentalCoroutinesApi::class)
fun author() =
metadata.stateFlow.flatMapLatest {
it.note.author
?.flow()
?.metadata
?.stateFlow ?: MutableStateFlow(null)
}
fun isInUse(): Boolean =
metadata.stateFlow.subscriptionCount.value > 0 ||
reports.stateFlow.subscriptionCount.value > 0 ||

View File

@@ -22,9 +22,7 @@ package com.vitorpamplona.amethyst.model
import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import com.vitorpamplona.amethyst.service.previews.UrlPreview
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
@Stable
@@ -34,6 +32,7 @@ object UrlCachedPreviewer {
suspend fun previewInfo(
url: String,
forceProxy: Boolean,
onReady: suspend (UrlPreviewState) -> Unit,
) {
cache[url]?.let {
@@ -41,39 +40,37 @@ object UrlCachedPreviewer {
return
}
BahaUrlPreview(
UrlPreview().fetch(
url,
object : IUrlPreviewCallback {
override suspend fun onComplete(urlInfo: UrlInfoItem) {
cache[url]?.let {
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
onReady(it)
return
}
}
val state =
if (urlInfo.fetchComplete() && urlInfo.url == url) {
UrlPreviewState.Loaded(urlInfo)
} else {
UrlPreviewState.Empty
}
cache.put(url, state)
onReady(state)
}
override suspend fun onFailed(throwable: Throwable) {
cache[url]?.let {
forceProxy,
onComplete = { urlInfo ->
cache[url]?.let {
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
onReady(it)
return
return@fetch
}
}
val state =
if (urlInfo.fetchComplete() && urlInfo.url == url) {
UrlPreviewState.Loaded(urlInfo)
} else {
UrlPreviewState.Empty
}
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
cache.put(url, state)
onReady(state)
}
cache.put(url, state)
onReady(state)
},
).fetchUrlPreview()
onFailed = { throwable ->
cache[url]?.let {
onReady(it)
return@fetch
}
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
cache.put(url, state)
onReady(state)
},
)
}
}

View File

@@ -218,6 +218,7 @@ class CashuProcessor {
suspend fun melt(
token: CashuToken,
lud16: String,
forceProxy: (url: String) -> Boolean,
onSuccess: (String, String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
@@ -231,10 +232,12 @@ class CashuProcessor {
// Make invoice and leave room for fees
milliSats = token.totalAmount * 1000,
message = "Calculate Fees for Cashu",
forceProxy = forceProxy,
onSuccess = { baseInvoice ->
feeCalculator(
token.mint,
baseInvoice,
forceProxy = forceProxy,
onSuccess = { fees ->
LightningAddressResolver()
.lnAddressInvoice(
@@ -242,8 +245,9 @@ class CashuProcessor {
// Make invoice and leave room for fees
milliSats = (token.totalAmount - fees) * 1000,
message = "Redeem Cashu",
forceProxy = forceProxy,
onSuccess = { invoice ->
meltInvoice(token, invoice, fees, onSuccess, onError, context)
meltInvoice(token, invoice, fees, forceProxy, onSuccess, onError, context)
},
onProgress = {},
onError = onError,
@@ -264,6 +268,7 @@ class CashuProcessor {
fun feeCalculator(
mintAddress: String,
invoice: String,
forceProxy: (String) -> Boolean,
onSuccess: (Int) -> Unit,
onError: (String, String) -> Unit,
context: Context,
@@ -271,8 +276,8 @@ class CashuProcessor {
checkNotInMainThread()
try {
val client = HttpClientManager.getHttpClient()
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
val client = HttpClientManager.getHttpClient(forceProxy(url))
val factory = Event.mapper.nodeFactory
@@ -329,13 +334,14 @@ class CashuProcessor {
token: CashuToken,
invoice: String,
fees: Int,
forceProxy: (String) -> Boolean,
onSuccess: (String, String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
try {
val client = HttpClientManager.getHttpClient()
val url = token.mint + "/melt" // Melt cashu tokens at Mint
val client = HttpClientManager.getHttpClient(forceProxy(url))
val factory = Event.mapper.nodeFactory

View File

@@ -48,11 +48,12 @@ class FileHeader(
fileUrl: String,
mimeType: String?,
dimPrecomputed: String?,
forceProxy: Boolean,
onReady: (FileHeader) -> Unit,
onError: (String) -> Unit,
) {
try {
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl, forceProxy)
if (imageData != null) {
prepare(imageData, mimeType, dimPrecomputed, onReady, onError)

View File

@@ -44,6 +44,7 @@ class Nip05NostrAddressVerifier {
suspend fun fetchNip05Json(
nip05: String,
forceProxy: (String) -> Boolean,
onSuccess: suspend (String) -> Unit,
onError: (String) -> Unit,
) = withContext(Dispatchers.IO) {
@@ -65,7 +66,7 @@ class Nip05NostrAddressVerifier {
.build()
// Fetchers MUST ignore any HTTP redirects given by the /.well-known/nostr.json endpoint.
HttpClientManager
.getHttpClient()
.getHttpClient(forceProxy(url))
.newBuilder()
.followRedirects(false)
.build()
@@ -90,6 +91,7 @@ class Nip05NostrAddressVerifier {
suspend fun verifyNip05(
nip05: String,
forceProxy: (String) -> Boolean,
onSuccess: suspend (String) -> Unit,
onError: (String) -> Unit,
) {
@@ -100,6 +102,7 @@ class Nip05NostrAddressVerifier {
fetchNip05Json(
nip05,
forceProxy,
onSuccess = {
checkNotInMainThread()
@@ -126,7 +129,7 @@ class Nip05NostrAddressVerifier {
val hexKey = nip05url?.get("names")?.get(user)?.asText()
if (hexKey == null) {
onError("Username not found in the NIP05 JSON")
onError("Username not found in the NIP05 JSON [$nip05]")
} else {
onSuccess(hexKey)
}

View File

@@ -60,6 +60,7 @@ object Nip11CachedRetriever {
suspend fun loadRelayInfo(
dirtyUrl: String,
forceProxy: Boolean,
onInfo: (Nip11RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
) {
@@ -74,23 +75,24 @@ object Nip11CachedRetriever {
if (TimeUtils.now() - doc.time < TimeUtils.ONE_MINUTE) {
// just wait.
} else {
retrieve(url, dirtyUrl, onInfo, onError)
retrieve(url, dirtyUrl, forceProxy, onInfo, onError)
}
} else if (doc is RetrieveResultError) {
if (TimeUtils.now() - doc.time < TimeUtils.ONE_HOUR) {
onError(dirtyUrl, doc.error, null)
} else {
retrieve(url, dirtyUrl, onInfo, onError)
retrieve(url, dirtyUrl, forceProxy, onInfo, onError)
}
}
} else {
retrieve(url, dirtyUrl, onInfo, onError)
retrieve(url, dirtyUrl, forceProxy, onInfo, onError)
}
}
private suspend fun retrieve(
url: String,
dirtyUrl: String,
forceProxy: Boolean,
onInfo: (Nip11RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
) {
@@ -98,6 +100,7 @@ object Nip11CachedRetriever {
retriever.loadRelayInfo(
url = url,
dirtyUrl = dirtyUrl,
forceProxy = forceProxy,
onInfo = {
checkNotInMainThread()
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
@@ -123,6 +126,7 @@ class Nip11Retriever {
suspend fun loadRelayInfo(
url: String,
dirtyUrl: String,
forceProxy: Boolean,
onInfo: (Nip11RelayInformation) -> Unit,
onError: (String, ErrorCode, String?) -> Unit,
) {
@@ -136,7 +140,7 @@ class Nip11Retriever {
.build()
HttpClientManager
.getHttpClientForUrl(dirtyUrl)
.getHttpClient(forceProxy)
.newCall(request)
.enqueue(
object : Callback {

View File

@@ -47,11 +47,14 @@ object Nip96MediaServers {
val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf()
suspend fun load(url: String): Nip96Retriever.ServerInfo {
suspend fun load(
url: String,
forceProxy: Boolean,
): Nip96Retriever.ServerInfo {
val cached = cache[url]
if (cached != null) return cached
val fetched = Nip96Retriever().loadInfo(url)
val fetched = Nip96Retriever().loadInfo(url, forceProxy)
cache[url] = fetched
return fetched
}
@@ -100,7 +103,10 @@ class Nip96Retriever {
)
}
suspend fun loadInfo(baseUrl: String): ServerInfo {
suspend fun loadInfo(
baseUrl: String,
forceProxy: Boolean,
): ServerInfo {
checkNotInMainThread()
val request: Request =
@@ -110,7 +116,7 @@ class Nip96Retriever {
.url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json")
.build()
HttpClientManager.getHttpClient().newCall(request).execute().use { response ->
HttpClientManager.getHttpClient(forceProxy).newCall(request).execute().use { response ->
checkNotInMainThread()
response.use {
val body = it.body.string()

View File

@@ -62,6 +62,7 @@ class Nip96Uploader(
sensitiveContent: String?,
server: Nip96MediaServers.ServerName,
contentResolver: ContentResolver,
forceProxy: (String) -> Boolean,
onProgress: (percentage: Float) -> Unit,
context: Context,
): PartialEvent {
@@ -69,6 +70,7 @@ class Nip96Uploader(
Nip96Retriever()
.loadInfo(
server.baseUrl,
forceProxy(server.baseUrl),
)
return uploadImage(
@@ -79,6 +81,7 @@ class Nip96Uploader(
sensitiveContent,
serverInfo,
contentResolver,
forceProxy,
onProgress,
context,
)
@@ -92,6 +95,7 @@ class Nip96Uploader(
sensitiveContent: String?,
server: Nip96Retriever.ServerInfo,
contentResolver: ContentResolver,
forceProxy: (String) -> Boolean,
onProgress: (percentage: Float) -> Unit,
context: Context,
): PartialEvent {
@@ -118,6 +122,7 @@ class Nip96Uploader(
alt,
sensitiveContent,
server,
forceProxy,
onProgress,
context,
)
@@ -130,6 +135,7 @@ class Nip96Uploader(
alt: String?,
sensitiveContent: String?,
server: Nip96Retriever.ServerInfo,
forceProxy: (String) -> Boolean,
onProgress: (percentage: Float) -> Unit,
context: Context,
): PartialEvent {
@@ -139,7 +145,7 @@ class Nip96Uploader(
val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClientManager.getHttpClient()
val client = HttpClientManager.getHttpClient(forceProxy(server.apiUrl))
val requestBuilder = Request.Builder()
val requestBody: RequestBody =
@@ -149,7 +155,7 @@ class Nip96Uploader(
.addFormDataPart("expiration", "")
.addFormDataPart("size", length.toString())
.also { body ->
alt?.let { body.addFormDataPart("alt", it) }
alt?.ifBlank { null }?.let { body.addFormDataPart("alt", it) }
sensitiveContent?.let { body.addFormDataPart("content-warning", it) }
contentType?.let { body.addFormDataPart("content_type", it) }
}.addFormDataPart(
@@ -182,7 +188,7 @@ class Nip96Uploader(
val result = parseResults(str)
if (!result.processingUrl.isNullOrBlank()) {
return waitProcessing(result, server, onProgress)
return waitProcessing(result, server, forceProxy, onProgress)
} else if (result.status == "success" && result.nip94Event != null) {
return result.nip94Event
} else {
@@ -222,12 +228,13 @@ class Nip96Uploader(
hash: String,
contentType: String?,
server: Nip96Retriever.ServerInfo,
forceProxy: (String) -> Boolean,
context: Context,
): Boolean {
val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClientManager.getHttpClient()
val client = HttpClientManager.getHttpClient(forceProxy(server.apiUrl))
val requestBuilder = Request.Builder()
@@ -261,9 +268,9 @@ class Nip96Uploader(
private suspend fun waitProcessing(
result: Nip96Result,
server: Nip96Retriever.ServerInfo,
forceProxy: (String) -> Boolean,
onProgress: (percentage: Float) -> Unit,
): PartialEvent {
val client = HttpClientManager.getHttpClient()
var currentResult = result
while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) {
@@ -276,6 +283,7 @@ class Nip96Uploader(
.url(result.processingUrl)
.build()
val client = HttpClientManager.getHttpClient(forceProxy(result.processingUrl))
client.newCall(request).execute().use {
if (it.isSuccessful) {
it.body.use { currentResult = parseResults(it.string()) }

View File

@@ -494,10 +494,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
if (this::account.isInitialized) {
account.createAuthEvent(relay, challenge) {
Client.send(
it,
relay.url,
)
Client.sendIfExists(it, relay)
}
}
}

View File

@@ -170,7 +170,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
authors = followsRelays,
@@ -185,7 +185,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
),
follows?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
types = setOf(FeedType.FOLLOWS),
filter =
SincePerRelayFilter(
tags = mapOf("p" to it),
@@ -246,7 +246,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
val follows = account.liveDiscoveryListAuthorsPerRelay.value
return TypedFilter(
types = setOf(FeedType.GLOBAL),
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
authors = follows,

View File

@@ -49,7 +49,10 @@ object OnlineChecker {
return false
}
fun isOnline(url: String?): Boolean {
fun isOnline(
url: String?,
forceProxy: Boolean,
): Boolean {
checkNotInMainThread()
if (url.isNullOrBlank()) return false
@@ -74,7 +77,7 @@ object OnlineChecker {
val client =
HttpClientManager
.getHttpClient()
.getHttpClient(forceProxy)
.newBuilder()
.eventListener(EventListener.NONE)
.protocols(listOf(Protocol.HTTP_1_1))
@@ -93,7 +96,7 @@ object OnlineChecker {
.get()
.build()
HttpClientManager.getHttpClient().newCall(request).execute().use {
HttpClientManager.getHttpClient(forceProxy).newCall(request).execute().use {
checkNotInMainThread()
it.isSuccessful
}

View File

@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.screen.loggedIn.collectSuccessfulSigningOperations
import com.vitorpamplona.amethyst.ui.stringRes
@@ -60,6 +61,7 @@ class ZapPaymentHandler(
message: String,
context: Context,
showErrorIfNoLnAddress: Boolean,
forceProxy: (String) -> Boolean,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
@@ -123,7 +125,7 @@ class ZapPaymentHandler(
onProgress(0.05f)
}
assembleAllInvoices(splitZapRequestPairs.toList(), amountMilliSats, message, showErrorIfNoLnAddress, onError, onProgress = {
assembleAllInvoices(splitZapRequestPairs.toList(), amountMilliSats, message, showErrorIfNoLnAddress, forceProxy, onError, onProgress = {
onProgress(it * 0.7f + 0.05f) // keeps within range.
}, context) {
if (it.isEmpty()) {
@@ -169,7 +171,7 @@ class ZapPaymentHandler(
}
class SignAllZapRequestsReturn(
val zapRequestJson: String,
val zapRequestJson: String?,
val user: User? = null,
)
@@ -215,9 +217,7 @@ class ZapPaymentHandler(
) + (authorRelayList ?: emptySet())
prepareZapRequestIfNeeded(note, pollOption, message, zapType, user, userRelayList) { zapRequestJson ->
if (zapRequestJson != null) {
onReady(SignAllZapRequestsReturn(zapRequestJson, user))
}
onReady(SignAllZapRequestsReturn(zapRequestJson, user))
}
}
},
@@ -230,6 +230,7 @@ class ZapPaymentHandler(
totalAmountMilliSats: Long,
message: String,
showErrorIfNoLnAddress: Boolean,
forceProxy: (String) -> Boolean,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
context: Context,
@@ -247,6 +248,7 @@ class ZapPaymentHandler(
zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.first.weight, totalWeight),
message = message,
showErrorIfNoLnAddress = showErrorIfNoLnAddress,
forceProxy = forceProxy,
onError = onError,
onProgressStep = { percentStepForThisPayment ->
progressAllPayments += percentStepForThisPayment / invoices.size
@@ -312,10 +314,11 @@ class ZapPaymentHandler(
private fun assembleInvoice(
splitSetup: ZapSplitSetup,
nostrZapRequest: String,
nostrZapRequest: String?,
zapValue: Long,
message: String,
showErrorIfNoLnAddress: Boolean = true,
forceProxy: (String) -> Boolean,
onError: (String, String) -> Unit,
onProgressStep: (percent: Float) -> Unit,
context: Context,
@@ -339,6 +342,7 @@ class ZapPaymentHandler(
milliSats = zapValue,
message = message,
nostrRequest = nostrZapRequest,
forceProxy = forceProxy,
onError = onError,
onProgress = {
val step = it - progressThisPayment

View File

@@ -36,8 +36,6 @@ import java.net.URLEncoder
import kotlin.coroutines.cancellation.CancellationException
class LightningAddressResolver {
val client = HttpClientManager.getHttpClient()
fun assembleUrl(lnaddress: String): String? {
val parts = lnaddress.split("@")
@@ -54,6 +52,7 @@ class LightningAddressResolver {
private fun fetchLightningAddressJson(
lnaddress: String,
forceProxy: (url: String) -> Boolean,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
@@ -74,6 +73,8 @@ class LightningAddressResolver {
return
}
val client = HttpClientManager.getHttpClient(forceProxy(url))
try {
val request: Request =
Request
@@ -121,6 +122,7 @@ class LightningAddressResolver {
milliSats: Long,
message: String,
nostrRequest: String? = null,
forceProxy: (url: String) -> Boolean,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
@@ -137,6 +139,8 @@ class LightningAddressResolver {
url += "&nostr=$encodedNostrRequest"
}
val client = HttpClientManager.getHttpClient(forceProxy(url))
val request: Request =
Request
.Builder()
@@ -161,6 +165,7 @@ class LightningAddressResolver {
milliSats: Long,
message: String,
nostrRequest: String? = null,
forceProxy: (url: String) -> Boolean,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
@@ -170,6 +175,7 @@ class LightningAddressResolver {
fetchLightningAddressJson(
lnaddress,
forceProxy,
onSuccess = { lnAddressJson ->
onProgress(0.4f)
@@ -210,6 +216,7 @@ class LightningAddressResolver {
milliSats,
message,
if (allowsNostr) nostrRequest else null,
forceProxy,
onSuccess = {
onProgress(0.6f)

View File

@@ -24,6 +24,7 @@ import android.app.NotificationManager
import android.content.Context
import android.util.Log
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AccountSettings
@@ -42,6 +43,7 @@ import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.signers.NostrSignerExternal
import com.vitorpamplona.quartz.utils.TimeUtils
import java.math.BigDecimal
import kotlin.coroutines.cancellation.CancellationException
@@ -84,6 +86,12 @@ class EventNotificationConsumer(
// TODO: Modify the external launcher to launch as different users.
// Right now it only registers if Amber has already approved this signature
val signer = account.createSigner()
if (signer is NostrSignerExternal) {
signer.launcher.registerLauncher(
launcher = { },
contentResolver = Amethyst.instance::contentResolverFn,
)
}
pushWrappedEvent.unwrapThrowing(signer) { notificationEvent ->
val consumed = LocalCache.hasConsumed(notificationEvent)

View File

@@ -22,11 +22,13 @@ package com.vitorpamplona.amethyst.service.notifications
import android.util.Log
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.events.RelayAuthEvent
import com.vitorpamplona.quartz.signers.NostrSignerExternal
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -71,6 +73,13 @@ class RegisterAccounts(
val signer = accountRelayPair.first.createSigner()
// TODO: Modify the external launcher to launch as different users.
// Right now it only registers if Amber has already approved this signature
if (signer is NostrSignerExternal) {
signer.launcher.registerLauncher(
launcher = { },
contentResolver = Amethyst.instance::contentResolverFn,
)
}
RelayAuthEvent.create(accountRelayPair.second, notificationToken, signer) { result ->
continuation.resume(result)
}
@@ -157,7 +166,8 @@ class RegisterAccounts(
.post(body)
.build()
val client = HttpClientManager.getHttpClient()
// Always try via Tor for Amethyst.
val client = HttpClientManager.getHttpClient(true)
val isSucess = client.newCall(request).execute().use { it.isSuccessful }
Log.i(tag, "Server registration $isSucess")

View File

@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.ots
import android.util.Log
import android.util.LruCache
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.ammolite.service.HttpClientManager
@@ -29,7 +30,12 @@ import com.vitorpamplona.quartz.ots.BlockHeader
import com.vitorpamplona.quartz.ots.exceptions.UrlException
import okhttp3.Request
class OkHttpBlockstreamExplorer : BitcoinExplorer {
class OkHttpBlockstreamExplorer(
val forceProxy: (String) -> Boolean,
) : BitcoinExplorer {
private val cacheHeaders = LruCache<String, BlockHeader>(100)
private val cacheHeights = LruCache<Int, String>(100)
/**
* Retrieve the block information from the block hash.
*
@@ -38,8 +44,13 @@ class OkHttpBlockstreamExplorer : BitcoinExplorer {
* @throws Exception desc
*/
override fun block(hash: String): BlockHeader {
val client = HttpClientManager.getHttpClient()
val url = "$BLOCKSTREAM_API_URL/block/$hash"
cacheHeaders.get(hash)?.let {
return it
}
val usingTor = forceProxy(BLOCKSTREAM_API_URL)
val url = "${getAPI(usingTor)}/block/$hash"
val client = HttpClientManager.getHttpClient(forceProxy(url))
val request =
Request
@@ -59,6 +70,9 @@ class OkHttpBlockstreamExplorer : BitcoinExplorer {
blockHeader.setTime(jsonObject["timestamp"].asInt().toString())
blockHeader.blockHash = hash
Log.d("OkHttpBlockstreamExplorer", "$BLOCKSTREAM_API_URL/block/$hash")
cacheHeaders.put(hash, blockHeader)
return blockHeader
} else {
throw UrlException("Couldn't open $url: " + it.message + " " + it.code)
@@ -75,9 +89,13 @@ class OkHttpBlockstreamExplorer : BitcoinExplorer {
*/
@Throws(Exception::class)
override fun blockHash(height: Int): String {
val client = HttpClientManager.getHttpClient()
cacheHeights[height]?.let {
return it
}
val url = "$BLOCKSTREAM_API_URL/block-height/$height"
val usingTor = forceProxy(BLOCKSTREAM_API_URL)
val url = "${getAPI(usingTor)}/block-height/$height"
val client = HttpClientManager.getHttpClient(usingTor)
val request =
Request
@@ -92,14 +110,24 @@ class OkHttpBlockstreamExplorer : BitcoinExplorer {
val blockHash = it.body.string()
Log.d("OkHttpBlockstreamExplorer", "$url $blockHash")
cacheHeights.put(height, blockHash)
return blockHash
} else {
throw UrlException(it.message)
throw UrlException("Couldn't open $url: " + it.message + " " + it.code)
}
}
}
companion object {
private const val BLOCKSTREAM_API_URL = "https://blockstream.info/api"
private const val MEMPOOL_API_URL = "https://mempool.space/api/"
fun getAPI(usingTor: Boolean) =
if (usingTor) {
MEMPOOL_API_URL
} else {
BLOCKSTREAM_API_URL
}
}
}

View File

@@ -38,6 +38,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
*/
class OkHttpCalendar(
val url: String,
val forceProxy: Boolean,
) : ICalendar {
/**
* Submitting a digest to remote calendar. Returns a com.eternitywall.ots.Timestamp committing to that digest.
@@ -51,7 +52,7 @@ class OkHttpCalendar(
@Throws(ExceededSizeException::class, UrlException::class, DeserializationException::class)
override fun submit(digest: ByteArray): Timestamp {
try {
val client = HttpClientManager.getHttpClient()
val client = HttpClientManager.getHttpClient(forceProxy)
val url = "$url/digest"
val mediaType = "application/x-www-form-urlencoded; charset=utf-8".toMediaType()
@@ -102,7 +103,7 @@ class OkHttpCalendar(
)
override fun getTimestamp(commitment: ByteArray): Timestamp {
try {
val client = HttpClientManager.getHttpClient()
val client = HttpClientManager.getHttpClient(forceProxy)
val url = url + "/timestamp/" + Hex.encode(commitment)
val request =

View File

@@ -36,6 +36,7 @@ import java.util.concurrent.BlockingQueue
class OkHttpCalendarAsyncSubmit(
private val url: String,
private val digest: ByteArray,
private val forceProxy: Boolean,
) : ICalendarAsyncSubmit {
private var queue: BlockingQueue<Optional<Timestamp>>? = null
@@ -45,7 +46,7 @@ class OkHttpCalendarAsyncSubmit(
@Throws(Exception::class)
override fun call(): Optional<Timestamp> {
val client = HttpClientManager.getHttpClient()
val client = HttpClientManager.getHttpClient(forceProxy)
val url = "$url/digest"
val mediaType = "application/x-www-form-urlencoded; charset=utf-8".toMediaType()

View File

@@ -24,11 +24,13 @@ import com.vitorpamplona.quartz.ots.CalendarBuilder
import com.vitorpamplona.quartz.ots.ICalendar
import com.vitorpamplona.quartz.ots.ICalendarAsyncSubmit
class OkHttpCalendarBuilder : CalendarBuilder {
override fun newSyncCalendar(url: String): ICalendar = OkHttpCalendar(url)
class OkHttpCalendarBuilder(
val forceProxy: (String) -> Boolean,
) : CalendarBuilder {
override fun newSyncCalendar(url: String): ICalendar = OkHttpCalendar(url, forceProxy(url))
override fun newAsyncCalendar(
url: String,
digest: ByteArray,
): ICalendarAsyncSubmit = OkHttpCalendarAsyncSubmit(url, digest)
): ICalendarAsyncSubmit = OkHttpCalendarAsyncSubmit(url, digest, forceProxy(url))
}

View File

@@ -28,17 +28,17 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.ammolite.service.HttpClientManager
import okhttp3.OkHttpClient
/**
* HLS LiveStreams cannot use cache.
*/
@UnstableApi
class CustomMediaSourceFactory : MediaSource.Factory {
private var cachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(Amethyst.instance.videoCache.get(HttpClientManager.getHttpClient()))
private var nonCachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(OkHttpDataSource.Factory(HttpClientManager.getHttpClient()))
class CustomMediaSourceFactory(
val okHttpClient: OkHttpClient,
) : MediaSource.Factory {
private var cachingFactory: MediaSource.Factory = DefaultMediaSourceFactory(Amethyst.instance.videoCache.get(okHttpClient))
private var nonCachingFactory: MediaSource.Factory = DefaultMediaSourceFactory(OkHttpDataSource.Factory(okHttpClient))
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
cachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)

View File

@@ -30,6 +30,7 @@ import androidx.media3.common.Player
import androidx.media3.common.Player.PositionInfo
import androidx.media3.common.Player.STATE_IDLE
import androidx.media3.common.Player.STATE_READY
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import com.vitorpamplona.amethyst.ui.MainActivity
@@ -40,7 +41,8 @@ import kotlinx.coroutines.launch
import kotlin.math.abs
class MultiPlayerPlaybackManager(
private val dataSourceFactory: androidx.media3.exoplayer.source.MediaSource.Factory? = null,
@UnstableApi
val dataSourceFactory: CustomMediaSourceFactory,
private val cachedPositions: VideoViewedPositionCache,
) {
// protects from LruCache killing playing sessions

View File

@@ -38,6 +38,7 @@ object PlaybackClientController {
controllerID: String,
videoUri: String,
callbackUri: String?,
proxyPort: Int? = 0,
context: Context,
onReady: (MediaController) -> Unit,
) {
@@ -48,6 +49,9 @@ object PlaybackClientController {
bundle.putString("id", controllerID)
bundle.putString("uri", videoUri)
bundle.putString("callbackUri", callbackUri)
proxyPort?.let {
bundle.putInt("proxyPort", it)
}
var session = cache.get(context.hashCode())
if (session == null) {

View File

@@ -30,18 +30,47 @@ import com.vitorpamplona.ammolite.service.HttpClientManager
class PlaybackService : MediaSessionService() {
private var videoViewedPositionCache = VideoViewedPositionCache()
private var managerAllInOne: MultiPlayerPlaybackManager? = null
private var managerAllInOneNoProxy: MultiPlayerPlaybackManager? = null
private var managerAllInOneProxy: MultiPlayerPlaybackManager? = null
@OptIn(UnstableApi::class)
fun lazyDS(): MultiPlayerPlaybackManager {
managerAllInOne?.let {
return it
}
fun lazyDS(proxyPort: Int): MultiPlayerPlaybackManager {
if (proxyPort <= 0) {
// no proxy
managerAllInOneNoProxy?.let {
return it
}
val newInstance = MultiPlayerPlaybackManager(CustomMediaSourceFactory(), videoViewedPositionCache)
managerAllInOne = newInstance
return newInstance
// creates new
val okHttp = HttpClientManager.getHttpClient(false)
val newInstance = MultiPlayerPlaybackManager(CustomMediaSourceFactory(okHttp), videoViewedPositionCache)
managerAllInOneNoProxy = newInstance
return newInstance
} else {
// with proxy, check if the port is the same.
managerAllInOneProxy?.let {
val okHttp = HttpClientManager.getHttpClient(true)
if (okHttp == it.dataSourceFactory.okHttpClient.proxy) {
return it
}
val toDestroyAllInOne = managerAllInOneProxy
val newInstance = MultiPlayerPlaybackManager(CustomMediaSourceFactory(okHttp), videoViewedPositionCache)
managerAllInOneProxy = newInstance
toDestroyAllInOne?.releaseAppPlayers()
return newInstance
}
// creates new
val okHttp = HttpClientManager.getHttpClient(true)
val newInstance = MultiPlayerPlaybackManager(CustomMediaSourceFactory(okHttp), videoViewedPositionCache)
managerAllInOneProxy = newInstance
return newInstance
}
}
// Create your Player and MediaSession in the onCreate lifecycle event
@@ -50,18 +79,6 @@ class PlaybackService : MediaSessionService() {
super.onCreate()
Log.d("Lifetime Event", "PlaybackService.onCreate")
// Stop all videos and recreates all managers when the proxy changes.
HttpClientManager.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
}
@OptIn(UnstableApi::class)
private fun onProxyUpdated() {
val toDestroyAllInOne = managerAllInOne
managerAllInOne = MultiPlayerPlaybackManager(CustomMediaSourceFactory(), videoViewedPositionCache)
toDestroyAllInOne?.releaseAppPlayers()
}
override fun onTaskRemoved(rootIntent: Intent?) {
@@ -73,9 +90,8 @@ class PlaybackService : MediaSessionService() {
override fun onDestroy() {
Log.d("Lifetime Event", "PlaybackService.onDestroy")
HttpClientManager.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
managerAllInOne?.releaseAppPlayers()
managerAllInOneProxy?.releaseAppPlayers()
managerAllInOneNoProxy?.releaseAppPlayers()
super.onDestroy()
}
@@ -88,14 +104,28 @@ class PlaybackService : MediaSessionService() {
super.onUpdateNotification(session, startInForegroundRequired)
// Overrides the notification with any player actually playing
managerAllInOne?.playingContent()?.forEach {
managerAllInOneProxy?.playingContent()?.forEach {
if (it.player.isPlaying) {
super.onUpdateNotification(it, startInForegroundRequired)
}
}
// Overrides again with playing with audio
managerAllInOne?.playingContent()?.forEach {
managerAllInOneProxy?.playingContent()?.forEach {
if (it.player.isPlaying && it.player.volume > 0) {
super.onUpdateNotification(it, startInForegroundRequired)
}
}
// Overrides the notification with any player actually playing
managerAllInOneNoProxy?.playingContent()?.forEach {
if (it.player.isPlaying) {
super.onUpdateNotification(it, startInForegroundRequired)
}
}
// Overrides again with playing with audio
managerAllInOneNoProxy?.playingContent()?.forEach {
if (it.player.isPlaying && it.player.volume > 0) {
super.onUpdateNotification(it, startInForegroundRequired)
}
@@ -108,8 +138,9 @@ class PlaybackService : MediaSessionService() {
val id = controllerInfo.connectionHints.getString("id") ?: return null
val uri = controllerInfo.connectionHints.getString("uri") ?: return null
val callbackUri = controllerInfo.connectionHints.getString("callbackUri")
val proxyPort = controllerInfo.connectionHints.getInt("proxyPort")
val manager = lazyDS()
val manager = lazyDS(proxyPort)
return manager.getMediaSession(
id,

View File

@@ -43,13 +43,16 @@ class VideoCache {
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
suspend fun initFileCache(context: Context) {
suspend fun initFileCache(
context: Context,
cachePath: File,
) {
exoDatabaseProvider = StandaloneDatabaseProvider(context)
withContext(Dispatchers.IO) {
simpleCache =
SimpleCache(
File(context.cacheDir, "exoplayer"),
cachePath,
leastRecentlyUsedCacheEvictor,
exoDatabaseProvider,
)
@@ -62,9 +65,8 @@ class VideoCache {
CacheDataSource
.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(
OkHttpDataSource.Factory(client),
).setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
.setUpstreamDataSourceFactory(OkHttpDataSource.Factory(client))
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
fun get(client: OkHttpClient): CacheDataSource.Factory {

View File

@@ -0,0 +1,111 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.previews
import com.vitorpamplona.amethyst.commons.preview.MetaTag
import com.vitorpamplona.amethyst.commons.preview.MetaTagsParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType
import okio.BufferedSource
import okio.ByteString.Companion.decodeHex
import okio.Options
import java.nio.charset.Charset
class HtmlParser {
companion object {
val ATTRIBUTE_VALUE_CHARSET = "charset"
val ATTRIBUTE_VALUE_HTTP_EQUIV = "http-equiv"
val CONTENT = "content"
// taken from okhttp
private val UNICODE_BOMS =
Options.of(
// UTF-8
"efbbbf".decodeHex(),
// UTF-16BE
"feff".decodeHex(),
// UTF-16LE
"fffe".decodeHex(),
// UTF-32BE
"0000ffff".decodeHex(),
// UTF-32LE
"ffff0000".decodeHex(),
)
private val RE_CONTENT_TYPE_CHARSET = Regex("""charset=([^;]+)""")
}
suspend fun parseHtml(
source: BufferedSource,
type: MediaType,
): Sequence<MetaTag> =
withContext(Dispatchers.IO) {
// sniff charset from Content-Type header or BOM
val sniffedCharset = type.charset() ?: source.readBomAsCharset()
if (sniffedCharset != null) {
val content = source.readByteArray().toString(sniffedCharset)
return@withContext MetaTagsParser.parse(content)
}
// if sniffing was failed, detect charset from content
val bodyBytes = source.readByteArray()
val charset = detectCharset(bodyBytes)
val content = bodyBytes.toString(charset)
return@withContext MetaTagsParser.parse(content)
}
private fun BufferedSource.readBomAsCharset(): Charset? =
when (select(UNICODE_BOMS)) {
0 -> Charsets.UTF_8
1 -> Charsets.UTF_16BE
2 -> Charsets.UTF_16LE
3 -> Charsets.UTF_32BE
4 -> Charsets.UTF_32LE
-1 -> null
else -> throw AssertionError()
}
private fun detectCharset(bodyBytes: ByteArray): Charset {
// try to detect charset from meta tags parsed from first 1024 bytes of body
val firstPart = String(bodyBytes, 0, 1024, Charset.forName("utf-8"))
val metaTags = MetaTagsParser.parse(firstPart)
metaTags.forEach { meta ->
val charsetAttr = meta.attr(ATTRIBUTE_VALUE_CHARSET)
if (charsetAttr.isNotEmpty()) {
runCatching { Charset.forName(charsetAttr) }.getOrNull()?.let {
return it
}
}
if (meta.attr(ATTRIBUTE_VALUE_HTTP_EQUIV).lowercase() == "content-type") {
RE_CONTENT_TYPE_CHARSET
.find(meta.attr(CONTENT))
?.let {
runCatching { Charset.forName(it.groupValues[1]) }.getOrNull()
}?.let {
return it
}
}
}
// defaults to UTF-8
return Charset.forName("utf-8")
}
}

View File

@@ -0,0 +1,127 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.previews
import com.vitorpamplona.amethyst.commons.preview.MetaTag
class OpenGraphParser {
class Result(
val title: String,
val description: String,
val image: String,
)
companion object {
val ATTRIBUTE_VALUE_PROPERTY = "property"
val ATTRIBUTE_VALUE_NAME = "name"
val ATTRIBUTE_VALUE_ITEMPROP = "itemprop"
// for <meta itemprop=... to get title
private val META_X_TITLE =
arrayOf(
"og:title",
"twitter:title",
"title",
)
// for <meta itemprop=... to get description
private val META_X_DESCRIPTION =
arrayOf(
"og:description",
"twitter:description",
"description",
)
// for <meta itemprop=... to get image
private val META_X_IMAGE =
arrayOf(
"og:image",
"twitter:image",
"image",
)
private val CONTENT = "content"
}
fun extractUrlInfo(metaTags: Sequence<MetaTag>): Result {
var title: String = ""
var description: String = ""
var image: String = ""
metaTags.forEach {
when (it.attr(ATTRIBUTE_VALUE_PROPERTY)) {
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
return Result(title, description, image)
}
}
return Result(title, description, image)
}
}

View File

@@ -0,0 +1,76 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.previews
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.ammolite.service.HttpClientManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
class UrlPreview {
suspend fun fetch(
url: String,
forceProxy: Boolean,
onComplete: suspend (urlInfo: UrlInfoItem) -> Unit,
onFailed: suspend (t: Throwable) -> Unit,
) = try {
onComplete(getDocument(url, forceProxy))
} catch (t: Throwable) {
if (t is CancellationException) throw t
onFailed(t)
}
suspend fun getDocument(
url: String,
forceProxy: Boolean,
): UrlInfoItem =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(url)
.get()
.build()
HttpClientManager.getHttpClient(forceProxy).newCall(request).execute().use {
checkNotInMainThread()
if (it.isSuccessful) {
val mimeType =
it.headers["Content-Type"]?.toMediaType()
?: throw IllegalArgumentException("Website returned unknown mimetype: ${it.headers["Content-Type"]}")
if (mimeType.type == "text" && mimeType.subtype == "html") {
val data = OpenGraphParser().extractUrlInfo(HtmlParser().parseHtml(it.body.source(), mimeType))
UrlInfoItem(url, data.title, data.description, data.image, mimeType)
} else if (mimeType.type == "image") {
UrlInfoItem(url, image = url, mimeType = mimeType)
} else if (mimeType.type == "video") {
UrlInfoItem(url, image = url, mimeType = mimeType)
} else {
throw IllegalArgumentException("Website returned unknown encoding for previews: $mimeType")
}
} else {
throw IllegalArgumentException("Website returned: " + it.code)
}
}
}
}

View File

@@ -1,244 +0,0 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.previews
import com.vitorpamplona.amethyst.commons.preview.MetaTag
import com.vitorpamplona.amethyst.commons.preview.MetaTagsParser
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.ammolite.service.HttpClientManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okio.BufferedSource
import okio.ByteString.Companion.decodeHex
import okio.Options
import java.nio.charset.Charset
private const val ATTRIBUTE_VALUE_PROPERTY = "property"
private const val ATTRIBUTE_VALUE_NAME = "name"
private const val ATTRIBUTE_VALUE_ITEMPROP = "itemprop"
private const val ATTRIBUTE_VALUE_CHARSET = "charset"
private const val ATTRIBUTE_VALUE_HTTP_EQUIV = "http-equiv"
// for <meta itemprop=... to get title
private val META_X_TITLE =
arrayOf(
"og:title",
"twitter:title",
"title",
)
// for <meta itemprop=... to get description
private val META_X_DESCRIPTION =
arrayOf(
"og:description",
"twitter:description",
"description",
)
// for <meta itemprop=... to get image
private val META_X_IMAGE =
arrayOf(
"og:image",
"twitter:image",
"image",
)
private const val CONTENT = "content"
suspend fun getDocument(
url: String,
timeOut: Int = 30000,
): UrlInfoItem =
withContext(Dispatchers.IO) {
val request: Request =
Request
.Builder()
.url(url)
.get()
.build()
HttpClientManager.getHttpClient().newCall(request).execute().use {
checkNotInMainThread()
if (it.isSuccessful) {
val mimeType =
it.headers["Content-Type"]?.toMediaType()
?: throw IllegalArgumentException(
"Website returned unknown mimetype: ${it.headers["Content-Type"]}",
)
if (mimeType.type == "text" && mimeType.subtype == "html") {
parseHtml(url, it.body.source(), mimeType)
} else if (mimeType.type == "image") {
UrlInfoItem(url, image = url, mimeType = mimeType)
} else if (mimeType.type == "video") {
UrlInfoItem(url, image = url, mimeType = mimeType)
} else {
throw IllegalArgumentException(
"Website returned unknown encoding for previews: $mimeType",
)
}
} else {
throw IllegalArgumentException("Website returned: " + it.code)
}
}
}
suspend fun parseHtml(
url: String,
source: BufferedSource,
type: MediaType,
): UrlInfoItem =
withContext(Dispatchers.IO) {
// sniff charset from Content-Type header or BOM
val sniffedCharset = type.charset() ?: source.readBomAsCharset()
if (sniffedCharset != null) {
val metaTags = MetaTagsParser.parse(source.readByteArray().toString(sniffedCharset))
return@withContext extractUrlInfo(url, metaTags, type)
}
// if sniffing was failed, detect charset from content
val bodyBytes = source.readByteArray()
val charset = detectCharset(bodyBytes)
val metaTags = MetaTagsParser.parse(bodyBytes.toString(charset))
return@withContext extractUrlInfo(url, metaTags, type)
}
// taken from okhttp
private val UNICODE_BOMS =
Options.of(
// UTF-8
"efbbbf".decodeHex(),
// UTF-16BE
"feff".decodeHex(),
// UTF-16LE
"fffe".decodeHex(),
// UTF-32BE
"0000ffff".decodeHex(),
// UTF-32LE
"ffff0000".decodeHex(),
)
private fun BufferedSource.readBomAsCharset(): Charset? =
when (select(UNICODE_BOMS)) {
0 -> Charsets.UTF_8
1 -> Charsets.UTF_16BE
2 -> Charsets.UTF_16LE
3 -> Charsets.UTF_32BE
4 -> Charsets.UTF_32LE
-1 -> null
else -> throw AssertionError()
}
private val RE_CONTENT_TYPE_CHARSET = Regex("""charset=([^;]+)""")
private fun detectCharset(bodyBytes: ByteArray): Charset {
// try to detect charset from meta tags parsed from first 1024 bytes of body
val firstPart = String(bodyBytes, 0, 1024, Charset.forName("utf-8"))
val metaTags = MetaTagsParser.parse(firstPart)
metaTags.forEach { meta ->
val charsetAttr = meta.attr(ATTRIBUTE_VALUE_CHARSET)
if (charsetAttr.isNotEmpty()) {
runCatching { Charset.forName(charsetAttr) }.getOrNull()?.let {
return it
}
}
if (meta.attr(ATTRIBUTE_VALUE_HTTP_EQUIV).lowercase() == "content-type") {
RE_CONTENT_TYPE_CHARSET
.find(meta.attr(CONTENT))
?.let {
runCatching { Charset.forName(it.groupValues[1]) }.getOrNull()
}?.let {
return it
}
}
}
// defaults to UTF-8
return Charset.forName("utf-8")
}
private fun extractUrlInfo(
url: String,
metaTags: Sequence<MetaTag>,
type: MediaType,
): UrlInfoItem {
var title: String = ""
var description: String = ""
var image: String = ""
metaTags.forEach {
when (it.attr(ATTRIBUTE_VALUE_PROPERTY)) {
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
return UrlInfoItem(url, title, description, image, type)
}
}
return UrlInfoItem(url, title, description, image, type)
}

View File

@@ -26,10 +26,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.adaptive.calculateDisplayFeatures
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
@@ -53,16 +50,3 @@ fun prepareSharedViewModel(act: MainActivity): SharedPreferencesViewModel {
return sharedPreferencesViewModel
}
@Composable
fun AppScreen(sharedPreferencesViewModel: SharedPreferencesViewModel) {
AmethystTheme(sharedPreferencesViewModel) {
val accountStateViewModel: AccountStateViewModel = viewModel()
LaunchedEffect(key1 = Unit) {
accountStateViewModel.tryLoginExistingAccountAsync()
}
AccountScreen(accountStateViewModel, sharedPreferencesViewModel)
}
}

View File

@@ -34,16 +34,22 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.debugState
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.ui.components.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.debugState
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
@@ -78,7 +84,16 @@ class MainActivity : AppCompatActivity() {
setContent {
val sharedPreferencesViewModel = prepareSharedViewModel(act = this)
AppScreen(sharedPreferencesViewModel = sharedPreferencesViewModel)
AmethystTheme(sharedPreferencesViewModel) {
val accountStateViewModel: AccountStateViewModel = viewModel()
LaunchedEffect(key1 = Unit) {
accountStateViewModel.tryLoginExistingAccountAsync()
}
AccountScreen(accountStateViewModel, sharedPreferencesViewModel)
}
}
}
@@ -95,6 +110,8 @@ class MainActivity : AppCompatActivity() {
checkLanguage(locales.get(0).language)
}
TorManager.startTor(this)
Log.d("Lifetime Event", "MainActivity.onResume")
// starts muted every time
@@ -141,6 +158,8 @@ class MainActivity : AppCompatActivity() {
(getSystemService(ConnectivityManager::class.java) as ConnectivityManager)
.unregisterNetworkCallback(networkCallback)
TorManager.stopTor(this)
super.onPause()
}
@@ -305,7 +324,7 @@ fun uriToRoute(uri: String?): String? =
uri?.let {
Nip47WalletConnect.parse(it)
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
Route.Home.base + "?nip47=" + encodedUri
Route.NIP47Setup.base + "?nip47=" + encodedUri
}
} catch (e: Exception) {
if (e is CancellationException) throw e

View File

@@ -94,9 +94,10 @@ import com.vitorpamplona.amethyst.ui.components.BechLink
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.UserLine
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -119,7 +120,7 @@ fun EditPostView(
edit: Note,
versionLookingAt: Note?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
val postViewModel: EditPostViewModel = viewModel()
postViewModel.prepare(edit, versionLookingAt, accountViewModel)
@@ -330,8 +331,8 @@ fun EditPostView(
ImageVideoDescription(
url,
accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent ->
postViewModel.upload(url, alt, sensitiveContent, false, server, accountViewModel::toast, context)
onAdd = { alt, server, sensitiveContent, mediaQuality ->
postViewModel.upload(url, alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
if (!server.isNip95) {
accountViewModel.account.settings.changeDefaultFileServer(server.server)
}

View File

@@ -107,11 +107,11 @@ open class EditPostViewModel : ViewModel() {
editedFromNote = edit
}
fun sendPost(relayList: List<RelaySetupInfo>? = null) {
fun sendPost(relayList: List<RelaySetupInfo>) {
viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) }
}
suspend fun innerSendPost(relayList: List<RelaySetupInfo>? = null) {
suspend fun innerSendPost(relayList: List<RelaySetupInfo>) {
if (accountViewModel == null) {
cancel()
return
@@ -148,6 +148,7 @@ open class EditPostViewModel : ViewModel() {
galleryUri: Uri,
alt: String?,
sensitiveContent: Boolean,
mediaQuality: Int,
isPrivate: Boolean = false,
server: ServerOption,
onError: (String, String) -> Unit,
@@ -192,6 +193,7 @@ open class EditPostViewModel : ViewModel() {
sensitiveContent = if (sensitiveContent) "" else null,
server = server.server,
contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onProgress = {},
context = context,
)
@@ -201,6 +203,7 @@ open class EditPostViewModel : ViewModel() {
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onError = {
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
},
@@ -223,6 +226,7 @@ open class EditPostViewModel : ViewModel() {
isUploadingImage = false
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, it))
},
mediaQuality = MediaCompressor().intToCompressorQuality(mediaQuality),
)
}
}
@@ -316,6 +320,7 @@ open class EditPostViewModel : ViewModel() {
localContentType: String?,
alt: String?,
sensitiveContent: Boolean,
forceProxy: (String) -> Boolean,
onError: (String) -> Unit = {},
context: Context,
) {
@@ -354,6 +359,7 @@ open class EditPostViewModel : ViewModel() {
fileUrl = imageUrl,
mimeType = remoteMimeType ?: localContentType,
dimPrecomputed = dim,
forceProxy = forceProxy(imageUrl),
onReady = { header: FileHeader ->
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
isUploadingImage = false

View File

@@ -27,7 +27,10 @@ import java.net.HttpURLConnection
import java.net.URL
class ImageDownloader {
suspend fun waitAndGetImage(imageUrl: String): ByteArray? {
suspend fun waitAndGetImage(
imageUrl: String,
forceProxy: Boolean,
): ByteArray? {
var imageData: ByteArray? = null
var tentatives = 0
@@ -35,11 +38,12 @@ class ImageDownloader {
while (imageData == null && tentatives < 15) {
imageData =
try {
// TODO: Migrate to OkHttp
HttpURLConnection.setFollowRedirects(true)
var url = URL(imageUrl)
var huc =
if (HttpClientManager.getDefaultProxy() != null) {
url.openConnection(HttpClientManager.getDefaultProxy()) as HttpURLConnection
if (forceProxy) {
url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection
} else {
url.openConnection() as HttpURLConnection
}
@@ -52,8 +56,8 @@ class ImageDownloader {
// open the new connnection again
url = URL(newUrl)
huc =
if (HttpClientManager.getDefaultProxy() != null) {
url.openConnection(HttpClientManager.getDefaultProxy()) as HttpURLConnection
if (forceProxy) {
url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection
} else {
url.openConnection() as HttpURLConnection
}

View File

@@ -20,9 +20,12 @@
*/
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Done
@@ -32,9 +35,14 @@ import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size16dp
@@ -50,15 +58,11 @@ fun InformationDialog(
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
onDismiss: () -> Unit,
) {
val stack = stringRes(id = R.string.stack)
val str =
val str = textContent ?: throwable.localizedMessage ?: throwable.message ?: throwable.javaClass.simpleName
val stack =
remember(throwable) {
val writer = StringWriter()
textContent?.let {
writer.append(it)
writer.append("\n\n")
}
writer.append(stack)
writer.append("\n")
throwable.printStackTrace(PrintWriter(writer))
@@ -66,13 +70,14 @@ fun InformationDialog(
writer.toString()
}
InformationDialog(title = title, textContent = str, buttonColors, onDismiss)
InformationDialog(title = title, textContent = str, moreInfo = stack, buttonColors, onDismiss)
}
@Composable
fun InformationDialog(
title: String,
textContent: String,
moreInfo: String? = null,
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
onDismiss: () -> Unit,
) {
@@ -85,20 +90,37 @@ fun InformationDialog(
}
},
confirmButton = {
Button(
onClick = onDismiss,
colors = buttonColors,
contentPadding = PaddingValues(horizontal = Size16dp),
Row(
modifier =
Modifier
.padding(all = 8.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
moreInfo?.let {
val clipboardManager = LocalClipboardManager.current
TextButton(onClick = {
clipboardManager.setText(AnnotatedString(it))
}) {
Text(stringRes(R.string.copy_stack_to_clipboard))
}
}
Button(
onClick = onDismiss,
colors = buttonColors,
contentPadding = PaddingValues(horizontal = Size16dp),
) {
Icon(
imageVector = Icons.Outlined.Done,
contentDescription = null,
)
Spacer(StdHorzSpacer)
Text(stringRes(R.string.error_dialog_button_ok))
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Outlined.Done,
contentDescription = null,
)
Spacer(StdHorzSpacer)
Text(stringRes(R.string.error_dialog_button_ok))
}
}
}
},

View File

@@ -75,12 +75,13 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchBarViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@@ -102,7 +103,7 @@ import kotlinx.coroutines.withContext
fun JoinUserOrChannelView(
onClose: () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
val searchBarViewModel: SearchBarViewModel =
viewModel(
@@ -126,7 +127,7 @@ fun JoinUserOrChannelView(
searchBarViewModel: SearchBarViewModel,
onClose: () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
Dialog(
onDismissRequest = {
@@ -183,7 +184,7 @@ fun JoinUserOrChannelView(
private fun RenderSearch(
searchBarViewModel: SearchBarViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
val listState = rememberLazyListState()
@@ -324,7 +325,7 @@ private fun RenderSearchResults(
searchBarViewModel: SearchBarViewModel,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
if (searchBarViewModel.isSearching) {
val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle()
@@ -347,7 +348,7 @@ private fun RenderSearchResults(
key = { _, item -> "u" + item.pubkeyHex },
) { _, item ->
UserComposeForChat(item, accountViewModel) {
accountViewModel.createChatRoomFor(item) { nav("Room/$it") }
accountViewModel.createChatRoomFor(item) { nav.nav("Room/$it") }
searchBarViewModel.clear()
}
@@ -366,7 +367,7 @@ private fun RenderSearchResults(
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
) {
nav("Channel/${item.idHex}")
nav.nav("Channel/${item.idHex}")
searchBarViewModel.clear()
}

View File

@@ -46,9 +46,10 @@ import okio.source
import java.io.File
import java.util.UUID
object ImageSaver {
fun saveImage(
object MediaSaverToDisk {
fun saveDownloadingIfNeeded(
videoUri: String?,
forceProxy: Boolean,
mimeType: String?,
localContext: Context,
onSuccess: () -> Any?,
@@ -56,14 +57,15 @@ object ImageSaver {
) {
if (videoUri != null) {
if (!videoUri.startsWith("file")) {
saveImage(
downloadAndSave(
context = localContext,
forceProxy = forceProxy,
url = videoUri,
onSuccess = onSuccess,
onError = onError,
)
} else {
saveImage(
save(
context = localContext,
localFile = videoUri.toUri().toFile(),
mimeType = mimeType,
@@ -79,13 +81,14 @@ object ImageSaver {
*
* @see PICTURES_SUBDIRECTORY
*/
fun saveImage(
fun downloadAndSave(
url: String,
forceProxy: Boolean,
context: Context,
onSuccess: () -> Any?,
onError: (Throwable) -> Any?,
) {
val client = HttpClientManager.getHttpClient()
val client = HttpClientManager.getHttpClient(forceProxy)
val request =
Request
@@ -142,7 +145,7 @@ object ImageSaver {
)
}
fun saveImage(
fun save(
localFile: File,
mimeType: String?,
context: Context,

View File

@@ -78,7 +78,8 @@ open class NewMediaModel : ViewModel() {
fun upload(
context: Context,
relayList: List<RelaySetupInfo>? = null,
relayList: List<RelaySetupInfo>,
mediaQuality: Int,
onError: (String) -> Unit = {},
) {
isUploadingImage = true
@@ -135,6 +136,7 @@ open class NewMediaModel : ViewModel() {
sensitiveContent = if (sensitiveContent) "" else null,
server = serverToUse.server,
contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onProgress = { percent: Float ->
uploadingPercentage.value = 0.2f + (0.2f * percent)
},
@@ -147,6 +149,7 @@ open class NewMediaModel : ViewModel() {
alt = alt,
sensitiveContent = sensitiveContent,
relayList = relayList,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onError = onError,
context,
)
@@ -166,6 +169,7 @@ open class NewMediaModel : ViewModel() {
uploadingDescription.value = null
onError(stringRes(context, R.string.error_when_compressing_media, it))
},
mediaQuality = MediaCompressor().intToCompressorQuality(mediaQuality),
)
}
}
@@ -188,7 +192,8 @@ open class NewMediaModel : ViewModel() {
localContentType: String?,
alt: String,
sensitiveContent: Boolean,
relayList: List<RelaySetupInfo>? = null,
relayList: List<RelaySetupInfo>,
forceProxy: (String) -> Boolean,
onError: (String) -> Unit = {},
context: Context,
) {
@@ -231,7 +236,7 @@ open class NewMediaModel : ViewModel() {
uploadingDescription.value = "Downloading"
uploadingPercentage.value = 0.60f
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl)
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl, forceProxy(imageUrl))
if (imageData != null) {
uploadingPercentage.value = 0.80f
@@ -282,7 +287,7 @@ open class NewMediaModel : ViewModel() {
mimeType: String?,
alt: String,
sensitiveContent: Boolean,
relayList: List<RelaySetupInfo>? = null,
relayList: List<RelaySetupInfo>,
onError: (String) -> Unit = {},
context: Context,
) {

View File

@@ -44,22 +44,26 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@@ -67,10 +71,12 @@ import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.FileServersEvent
import kotlinx.collections.immutable.toImmutableList
@@ -84,7 +90,7 @@ fun NewMediaView(
onClose: () -> Unit,
postViewModel: NewMediaModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
val account = accountViewModel.account
val resolver = LocalContext.current.contentResolver
@@ -100,6 +106,9 @@ fun NewMediaView(
var showRelaysDialog by remember { mutableStateOf(false) }
var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() }
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
var mediaQualitySlider by remember { mutableIntStateOf(1) }
Dialog(
onDismissRequest = { onClose() },
properties =
@@ -159,7 +168,7 @@ fun NewMediaView(
PostButton(
onPost = {
onClose()
postViewModel.upload(context, relayList) {
postViewModel.upload(context, relayList, mediaQualitySlider) {
accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
}
postViewModel.selectedServer?.let {
@@ -179,6 +188,60 @@ fun NewMediaView(
modifier = Modifier.fillMaxWidth().verticalScroll(scrollState),
) {
ImageVideoPost(postViewModel, accountViewModel)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
.padding(vertical = 8.dp),
) {
Column(
modifier = Modifier.weight(1.0f),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Text(
text = stringRes(context, R.string.media_compression_quality_label),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(context, R.string.media_compression_quality_explainer),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(modifier = Modifier.fillMaxWidth()) {
Text(
text =
when (mediaQualitySlider) {
0 -> stringRes(R.string.media_compression_quality_low)
1 -> stringRes(R.string.media_compression_quality_medium)
2 -> stringRes(R.string.media_compression_quality_high)
3 -> stringRes(R.string.media_compression_quality_uncompressed)
else -> stringRes(R.string.media_compression_quality_medium)
},
modifier = Modifier.align(Alignment.Center),
)
}
Slider(
value = mediaQualitySlider.toFloat(),
onValueChange = { mediaQualitySlider = it.toInt() },
valueRange = 0f..3f,
steps = 2,
)
}
}
}
}
}

View File

@@ -27,6 +27,8 @@ import android.os.Build
import android.util.Log
import android.util.Size
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
@@ -56,6 +58,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material.icons.filled.LocationOff
import androidx.compose.material.icons.filled.LocationOn
@@ -88,6 +91,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -117,6 +121,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
@@ -135,6 +140,7 @@ import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.components.ZapRaiserRequest
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.CancelIcon
import com.vitorpamplona.amethyst.ui.note.CloseIcon
@@ -193,8 +199,9 @@ fun NewPostView(
draft: Note? = null,
enableMessageInterface: Boolean = false,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
val lifecycleOwner = LocalLifecycleOwner.current
val postViewModel: NewPostViewModel = viewModel()
postViewModel.wantsDirectMessage = enableMessageInterface
@@ -204,6 +211,9 @@ fun NewPostView(
val scope = rememberCoroutineScope()
var showRelaysDialog by remember { mutableStateOf(false) }
var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() }
var showCamera by remember {
mutableStateOf(true)
}
LaunchedEffect(key1 = postViewModel.draftTag) {
launch(Dispatchers.IO) {
@@ -492,8 +502,8 @@ fun NewPostView(
ImageVideoDescription(
url,
accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent ->
postViewModel.upload(url, alt, sensitiveContent, false, server, accountViewModel::toast, context)
onAdd = { alt, server, sensitiveContent, mediaQuality ->
postViewModel.upload(url, alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
if (!server.isNip95) {
accountViewModel.account.settings.changeDefaultFileServer(server.server)
}
@@ -561,7 +571,6 @@ fun NewPostView(
@Composable
private fun BottomRowActions(postViewModel: NewPostViewModel) {
val scrollState = rememberScrollState()
Row(
modifier =
Modifier
@@ -578,6 +587,12 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) {
postViewModel.selectImage(it)
}
TakePictureButton(
onPictureTaken = {
postViewModel.selectImage(it)
},
)
if (postViewModel.canUsePoll) {
// These should be hashtag recommendations the user selects in the future.
// val hashtag = stringRes(R.string.poll_hashtag)
@@ -623,6 +638,59 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) {
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun TakePictureButton(onPictureTaken: (Uri) -> Unit) {
var imageUri by remember { mutableStateOf<Uri?>(null) }
val scope = rememberCoroutineScope()
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture(),
) { success ->
if (success) {
imageUri?.let {
onPictureTaken(it)
}
}
}
val context = LocalContext.current
val cameraPermissionState =
rememberPermissionState(
Manifest.permission.CAMERA,
onPermissionResult = {
if (it) {
scope.launch(Dispatchers.IO) {
imageUri = getPhotoUri(context)
imageUri?.let { uri -> launcher.launch(uri) }
}
}
},
)
Box {
IconButton(
modifier = Modifier.align(Alignment.Center),
onClick = {
if (cameraPermissionState.status.isGranted) {
scope.launch(Dispatchers.IO) {
imageUri = getPhotoUri(context)
imageUri?.let { uri -> launcher.launch(uri) }
}
} else {
cameraPermissionState.launchPermissionRequest()
}
},
) {
Icon(
imageVector = Icons.Default.CameraAlt,
contentDescription = stringRes(id = R.string.upload_image),
modifier = Modifier.height(25.dp),
tint = MaterialTheme.colorScheme.onBackground,
)
}
}
}
@Composable
private fun PollField(postViewModel: NewPostViewModel) {
val optionsList = postViewModel.pollOptions
@@ -1609,7 +1677,7 @@ fun CreateButton(
fun ImageVideoDescription(
uri: Uri,
defaultServer: Nip96MediaServers.ServerName,
onAdd: (String, ServerOption, Boolean) -> Unit,
onAdd: (String, ServerOption, Boolean, Int) -> Unit,
onCancel: () -> Unit,
onError: (Int) -> Unit,
accountViewModel: AccountViewModel,
@@ -1668,6 +1736,9 @@ fun ImageVideoDescription(
var message by remember { mutableStateOf("") }
var sensitiveContent by remember { mutableStateOf(false) }
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
var mediaQualitySlider by remember { mutableIntStateOf(1) }
Column(
modifier =
Modifier
@@ -1845,12 +1916,67 @@ fun ImageVideoDescription(
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
.padding(vertical = 8.dp),
) {
Column(
modifier = Modifier.weight(1.0f),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Text(
text = stringRes(R.string.media_compression_quality_label),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(R.string.media_compression_quality_explainer),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(modifier = Modifier.fillMaxWidth()) {
Text(
text =
when (mediaQualitySlider) {
0 -> stringRes(R.string.media_compression_quality_low)
1 -> stringRes(R.string.media_compression_quality_medium)
2 -> stringRes(R.string.media_compression_quality_high)
3 -> stringRes(R.string.media_compression_quality_uncompressed)
else -> stringRes(R.string.media_compression_quality_medium)
},
modifier = Modifier.align(Alignment.Center),
)
}
Slider(
value = mediaQualitySlider.toFloat(),
onValueChange = { mediaQualitySlider = it.toInt() },
valueRange = 0f..3f,
steps = 2,
)
}
}
Button(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
onClick = { onAdd(message, selectedServer, sensitiveContent) },
onClick = { onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider) },
shape = QuoteBorder,
colors =
ButtonDefaults.buttonColors(

View File

@@ -473,7 +473,7 @@ open class NewPostViewModel : ViewModel() {
urlPreview = findUrlInMessage()
}
fun sendPost(relayList: List<RelaySetupInfo>? = null) {
fun sendPost(relayList: List<RelaySetupInfo>) {
viewModelScope.launch(Dispatchers.IO) {
innerSendPost(relayList, null)
accountViewModel?.deleteDraft(draftTag)
@@ -481,18 +481,18 @@ open class NewPostViewModel : ViewModel() {
}
}
fun sendDraft(relayList: List<RelaySetupInfo>? = null) {
fun sendDraft(relayList: List<RelaySetupInfo>) {
viewModelScope.launch {
sendDraftSync(relayList)
}
}
suspend fun sendDraftSync(relayList: List<RelaySetupInfo>? = null) {
suspend fun sendDraftSync(relayList: List<RelaySetupInfo>) {
innerSendPost(relayList, draftTag)
}
private suspend fun innerSendPost(
relayList: List<RelaySetupInfo>? = null,
relayList: List<RelaySetupInfo>,
localDraft: String?,
) = withContext(Dispatchers.IO) {
if (accountViewModel == null) {
@@ -834,6 +834,7 @@ open class NewPostViewModel : ViewModel() {
galleryUri: Uri,
alt: String?,
sensitiveContent: Boolean,
mediaQuality: Int,
isPrivate: Boolean = false,
server: ServerOption,
onError: (title: String, message: String) -> Unit,
@@ -878,6 +879,7 @@ open class NewPostViewModel : ViewModel() {
sensitiveContent = if (sensitiveContent) "" else null,
server = server.server,
contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onProgress = {},
context = context,
)
@@ -887,6 +889,7 @@ open class NewPostViewModel : ViewModel() {
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onError = {
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
},
@@ -909,6 +912,7 @@ open class NewPostViewModel : ViewModel() {
isUploadingImage = false
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, it))
},
mediaQuality = MediaCompressor().intToCompressorQuality(mediaQuality),
)
}
}
@@ -1144,6 +1148,7 @@ open class NewPostViewModel : ViewModel() {
localContentType: String?,
alt: String?,
sensitiveContent: Boolean,
forceProxy: (String) -> Boolean,
onError: (message: String) -> Unit,
context: Context,
) {
@@ -1182,6 +1187,7 @@ open class NewPostViewModel : ViewModel() {
fileUrl = imageUrl,
mimeType = remoteMimeType ?: localContentType,
dimPrecomputed = dim,
forceProxy = forceProxy(imageUrl),
onReady = { header: FileHeader ->
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
isUploadingImage = false

View File

@@ -30,6 +30,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.events.GitHubIdentity
@@ -186,6 +187,7 @@ class NewUserMetadataViewModel : ViewModel() {
sensitiveContent = null,
server = account.settings.defaultFileServer,
contentResolver = contentResolver,
forceProxy = account::shouldUseTorForNIP96,
onProgress = {},
context = context,
)
@@ -211,6 +213,8 @@ class NewUserMetadataViewModel : ViewModel() {
onError(stringRes(context, R.string.error_when_compressing_media), stringRes(context, it))
},
// Use MEDIUM quality as default
mediaQuality = CompressorQuality.MEDIUM,
)
}
}

View File

@@ -40,6 +40,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size16dp
@@ -52,7 +54,7 @@ fun NotifyRequestDialog(
textContent: String,
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
onDismiss: () -> Unit,
) {
AlertDialog(
@@ -72,7 +74,7 @@ fun NotifyRequestDialog(
id = textContent,
callbackUri = null,
accountViewModel = accountViewModel,
nav = nav,
nav = rememberExtendedNav(nav, onDismiss),
)
},
confirmButton = {

View File

@@ -48,6 +48,7 @@ import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.ui.actions.relays.RelayInformationDialog
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@@ -74,7 +75,7 @@ fun RelaySelectionDialog(
onClose: () -> Unit,
onPost: (list: ImmutableList<RelaySetupInfo>) -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
val context = LocalContext.current

View File

@@ -1,164 +0,0 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import android.Manifest
import android.os.Build
import android.widget.Toast
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import kotlinx.coroutines.launch
import java.io.File
/**
* A button to save the remote image to the gallery. May require a storage permission.
*
* @param url URL of the image
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun SaveToGallery(url: String) {
val localContext = LocalContext.current
val scope = rememberCoroutineScope()
fun saveImage() {
ImageSaver.saveImage(
context = localContext,
url = url,
onSuccess = {
scope.launch {
Toast
.makeText(
localContext,
stringRes(localContext, R.string.image_saved_to_the_gallery),
Toast.LENGTH_SHORT,
).show()
}
},
onError = {
scope.launch {
Toast
.makeText(
localContext,
stringRes(localContext, R.string.failed_to_save_the_image),
Toast.LENGTH_SHORT,
).show()
}
},
)
}
val writeStoragePermissionState =
rememberPermissionState(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
) { isGranted ->
if (isGranted) {
saveImage()
}
}
OutlinedButton(
onClick = {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
writeStoragePermissionState.status.isGranted
) {
saveImage()
} else {
writeStoragePermissionState.launchPermissionRequest()
}
},
) {
Text(text = stringRes(id = R.string.save))
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun SaveToGallery(
localFile: File,
mimeType: String?,
) {
val localContext = LocalContext.current
val scope = rememberCoroutineScope()
fun saveImage() {
ImageSaver.saveImage(
context = localContext,
localFile = localFile,
mimeType = mimeType,
onSuccess = {
scope.launch {
Toast
.makeText(
localContext,
stringRes(localContext, R.string.image_saved_to_the_gallery),
Toast.LENGTH_SHORT,
).show()
}
},
onError = {
scope.launch {
Toast
.makeText(
localContext,
stringRes(localContext, R.string.failed_to_save_the_image),
Toast.LENGTH_SHORT,
).show()
}
},
)
}
val writeStoragePermissionState =
rememberPermissionState(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
) { isGranted ->
if (isGranted) {
saveImage()
}
}
OutlinedButton(
onClick = {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
writeStoragePermissionState.status.isGranted
) {
saveImage()
} else {
writeStoragePermissionState.launchPermissionRequest()
}
},
shape = ButtonBorder,
) {
Text(text = stringRes(id = R.string.save))
}
}

View File

@@ -20,8 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
@@ -52,6 +54,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@@ -60,6 +63,10 @@ import com.vitorpamplona.amethyst.ui.GetMediaActivityResultContract
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
@OptIn(ExperimentalPermissionsApi::class)
@@ -125,6 +132,23 @@ private fun UploadBoxButton(
}
}
fun getPhotoUri(context: Context): Uri {
val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val storageDir: File? = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
return File
.createTempFile(
"JPEG_${timeStamp}_",
".jpg",
storageDir,
).let {
FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
it,
)
}
}
val DefaultAnimationColors =
listOf(
Color(0xFF5851D8),

View File

@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.actions.relays.SettingsCategoryWithButton
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding
@@ -70,7 +71,7 @@ import com.vitorpamplona.amethyst.ui.theme.grayText
fun MediaServersListView(
onClose: () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
val mediaServersViewModel: MediaServersViewModel = viewModel()
val mediaServersState by mediaServersViewModel.fileServers.collectAsStateWithLifecycle()

View File

@@ -44,6 +44,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@@ -55,7 +56,7 @@ import com.vitorpamplona.amethyst.ui.theme.imageModifier
fun AddDMRelayListDialog(
onClose: () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
val postViewModel: DMRelayListViewModel = viewModel()

View File

@@ -44,6 +44,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@@ -55,7 +56,7 @@ import com.vitorpamplona.amethyst.ui.theme.imageModifier
fun AddSearchRelayListDialog(
onClose: () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
val postViewModel: SearchRelayListViewModel = viewModel()

View File

@@ -51,6 +51,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@@ -61,13 +63,23 @@ import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.ammolite.relays.Constants
import com.vitorpamplona.ammolite.relays.RelayStat
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AllRelayListView(
onClose: () -> Unit,
relayToAdd: String = "",
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
MappedAllRelayListView(onClose, relayToAdd, accountViewModel, rememberExtendedNav(nav, onClose))
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MappedAllRelayListView(
onClose: () -> Unit,
relayToAdd: String = "",
accountViewModel: AccountViewModel,
newNav: INav,
) {
val kind3ViewModel: Kind3RelayListViewModel = viewModel()
val kind3FeedState by kind3ViewModel.relays.collectAsStateWithLifecycle()
@@ -179,7 +191,7 @@ fun AllRelayListView(
Modifier.padding(bottom = 8.dp),
)
}
renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, onClose, nav)
renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, newNav)
item {
SettingsCategory(
@@ -187,7 +199,7 @@ fun AllRelayListView(
stringRes(R.string.public_notif_section_explainer),
)
}
renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, onClose, nav)
renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, newNav)
item {
SettingsCategory(
@@ -195,7 +207,7 @@ fun AllRelayListView(
stringRes(R.string.private_inbox_section_explainer),
)
}
renderDMItems(dmFeedState, dmViewModel, accountViewModel, onClose, nav)
renderDMItems(dmFeedState, dmViewModel, accountViewModel, newNav)
item {
SettingsCategory(
@@ -203,7 +215,7 @@ fun AllRelayListView(
stringRes(R.string.private_outbox_section_explainer),
)
}
renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, onClose, nav)
renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, newNav)
item {
SettingsCategoryWithButton(
@@ -214,7 +226,7 @@ fun AllRelayListView(
},
)
}
renderSearchItems(searchFeedState, searchViewModel, accountViewModel, onClose, nav)
renderSearchItems(searchFeedState, searchViewModel, accountViewModel, newNav)
item {
SettingsCategory(
@@ -222,7 +234,7 @@ fun AllRelayListView(
stringRes(R.string.local_section_explainer),
)
}
renderLocalItems(localFeedState, localViewModel, accountViewModel, onClose, nav)
renderLocalItems(localFeedState, localViewModel, accountViewModel, newNav)
item {
SettingsCategoryWithButton(
@@ -233,7 +245,7 @@ fun AllRelayListView(
},
)
}
renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, onClose, nav, relayToAdd)
renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, newNav, relayToAdd)
if (kind3Proposals.isNotEmpty()) {
item {
@@ -242,7 +254,7 @@ fun AllRelayListView(
stringRes(R.string.kind_3_recommended_section_description),
)
}
renderKind3ProposalItems(kind3Proposals, kind3ViewModel, accountViewModel, onClose, nav)
renderKind3ProposalItems(kind3Proposals, kind3ViewModel, accountViewModel, newNav)
}
}
}

View File

@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
@@ -39,7 +40,7 @@ fun BasicRelaySetupInfoDialog(
item: BasicRelaySetupInfo,
onDelete: (BasicRelaySetupInfo) -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) }
val context = LocalContext.current

View File

@@ -64,6 +64,7 @@ abstract class BasicRelaySetupInfoModel : ViewModel() {
_relays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo(
dirtyUrl = item.url,
forceProxy = account.shouldUseTorForDirty(item.url),
onInfo = {
togglePaidRelay(item, it.limitation?.payment_required ?: false)
},

View File

@@ -29,6 +29,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@@ -38,15 +40,16 @@ fun DMRelayList(
postViewModel: DMRelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
nav: INav,
) {
val newNav = rememberExtendedNav(nav, onClose)
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
Row(verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = FeedPadding,
) {
renderDMItems(feedState, postViewModel, accountViewModel, onClose, nav)
renderDMItems(feedState, postViewModel, accountViewModel, newNav)
}
}
}
@@ -55,18 +58,15 @@ fun LazyListScope.renderDMItems(
feedState: List<BasicRelaySetupInfo>,
postViewModel: DMRelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
nav: INav,
) {
itemsIndexed(feedState, key = { _, item -> "DM" + item.url }) { index, item ->
BasicRelaySetupInfoDialog(
item,
onDelete = { postViewModel.deleteRelay(item) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
nav = nav,
)
}
item {

View File

@@ -71,6 +71,8 @@ import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -102,12 +104,14 @@ fun Kind3RelayListView(
postViewModel: Kind3RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
nav: INav,
relayToAdd: String,
) {
val newNav = rememberExtendedNav(nav, onClose)
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
LazyColumn(contentPadding = FeedPadding) {
renderKind3Items(feedState, postViewModel, accountViewModel, onClose, nav, relayToAdd)
renderKind3Items(feedState, postViewModel, accountViewModel, newNav, relayToAdd)
}
}
}
@@ -116,8 +120,7 @@ fun LazyListScope.renderKind3Items(
feedState: List<Kind3BasicRelaySetupInfo>,
postViewModel: Kind3RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
nav: INav,
relayToAdd: String,
) {
itemsIndexed(feedState, key = { _, item -> "kind3" + item.url }) { index, item ->
@@ -132,10 +135,8 @@ fun LazyListScope.renderKind3Items(
onToggleSearch = { postViewModel.toggleSearch(it) },
onDelete = { postViewModel.deleteRelay(it) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
nav = nav,
)
}
item {
@@ -148,8 +149,7 @@ fun LazyListScope.renderKind3ProposalItems(
feedState: List<Kind3RelayProposalSetupInfo>,
postViewModel: Kind3RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
nav: INav,
) {
itemsIndexed(feedState, key = { _, item -> "kind3proposal" + item.url }) { index, item ->
Kind3RelaySetupInfoProposalDialog(
@@ -158,10 +158,7 @@ fun LazyListScope.renderKind3ProposalItems(
postViewModel.addRelay(item)
},
accountViewModel = accountViewModel,
nav = {
onClose()
nav(it)
},
nav = nav,
)
HorizontalDivider(
thickness = DividerThickness,
@@ -214,7 +211,7 @@ fun LoadRelayInfo(
onToggleSearch: (Kind3BasicRelaySetupInfo) -> Unit,
onDelete: (Kind3BasicRelaySetupInfo) -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) }
val context = LocalContext.current

View File

@@ -80,6 +80,7 @@ class Kind3RelayListViewModel : ViewModel() {
_relays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo(
dirtyUrl = item.url,
forceProxy = account.shouldUseTorForDirty(item.url),
onInfo = {
togglePaidRelay(item, it.limitation?.payment_required ?: false)
},

View File

@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
@@ -39,7 +40,7 @@ fun Kind3RelaySetupInfoProposalDialog(
item: Kind3RelayProposalSetupInfo,
onAdd: (Kind3RelayProposalSetupInfo) -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) }
val context = LocalContext.current

View File

@@ -47,6 +47,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.AddRelayButton
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserPicture
@@ -72,7 +74,7 @@ fun Kind3RelaySetupInfoProposalRow(
onAdd: () -> Unit,
onClick: () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
Column(
Modifier
@@ -138,7 +140,6 @@ fun Kind3RelaySetupInfoProposalRow(
@Preview
@Composable
fun UsedByPreview() {
val accountViewModel = mockAccountViewModel()
ThemeComparisonColumn {
UsedBy(
item =
@@ -151,9 +152,9 @@ fun UsedByPreview() {
paidRelay = false,
users = listOf("User1", "User2", "User3", "User4"),
),
accountViewModel = accountViewModel,
) {
}
accountViewModel = mockAccountViewModel(),
nav = EmptyNav,
)
}
}
@@ -162,7 +163,7 @@ fun UsedByPreview() {
fun UsedBy(
item: Kind3RelayProposalSetupInfo,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
FlowRow(verticalArrangement = Arrangement.Center) {
item.users.getOrNull(0)?.let {

View File

@@ -29,6 +29,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@@ -38,15 +40,16 @@ fun LocalRelayList(
postViewModel: LocalRelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
nav: INav,
) {
val newNav = rememberExtendedNav(nav, onClose)
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
Row(verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = FeedPadding,
) {
renderLocalItems(feedState, postViewModel, accountViewModel, onClose, nav)
renderLocalItems(feedState, postViewModel, accountViewModel, newNav)
}
}
}
@@ -55,18 +58,15 @@ fun LazyListScope.renderLocalItems(
feedState: List<BasicRelaySetupInfo>,
postViewModel: LocalRelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
nav: INav,
) {
itemsIndexed(feedState, key = { _, item -> "Local" + item.url }) { index, item ->
BasicRelaySetupInfoDialog(
item,
onDelete = { postViewModel.deleteRelay(item) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
nav,
)
}
item {

View File

@@ -29,6 +29,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@@ -38,8 +40,10 @@ fun Nip65RelayList(
postViewModel: Nip65RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
nav: INav,
) {
val newNav = rememberExtendedNav(nav, onClose)
val homeFeedState by postViewModel.homeRelays.collectAsStateWithLifecycle()
val notifFeedState by postViewModel.notificationRelays.collectAsStateWithLifecycle()
@@ -47,8 +51,8 @@ fun Nip65RelayList(
LazyColumn(
contentPadding = FeedPadding,
) {
renderNip65HomeItems(homeFeedState, postViewModel, accountViewModel, onClose, nav)
renderNip65NotifItems(notifFeedState, postViewModel, accountViewModel, onClose, nav)
renderNip65HomeItems(homeFeedState, postViewModel, accountViewModel, newNav)
renderNip65NotifItems(notifFeedState, postViewModel, accountViewModel, newNav)
}
}
}
@@ -57,18 +61,15 @@ fun LazyListScope.renderNip65HomeItems(
feedState: List<BasicRelaySetupInfo>,
postViewModel: Nip65RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
nav: INav,
) {
itemsIndexed(feedState, key = { _, item -> "Nip65Home" + item.url }) { index, item ->
BasicRelaySetupInfoDialog(
item,
onDelete = { postViewModel.deleteHomeRelay(item) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
nav,
)
}
item {
@@ -81,18 +82,15 @@ fun LazyListScope.renderNip65NotifItems(
feedState: List<BasicRelaySetupInfo>,
postViewModel: Nip65RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
nav: INav,
) {
itemsIndexed(feedState, key = { _, item -> "Nip65Notif" + item.url }) { index, item ->
BasicRelaySetupInfoDialog(
item,
onDelete = { postViewModel.deleteNotifRelay(item) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
nav,
)
}
item {

View File

@@ -83,6 +83,7 @@ class Nip65RelayListViewModel : ViewModel() {
_homeRelays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo(
dirtyUrl = item.url,
forceProxy = account.shouldUseTorForDirty(item.url),
onInfo = {
toggleHomePaidRelay(item, it.limitation?.payment_required ?: false)
},
@@ -93,6 +94,7 @@ class Nip65RelayListViewModel : ViewModel() {
_notificationRelays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo(
dirtyUrl = item.url,
forceProxy = account.shouldUseTorForDirty(item.url),
onInfo = {
toggleNotifPaidRelay(item, it.limitation?.payment_required ?: false)
},

View File

@@ -29,6 +29,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@@ -38,15 +40,16 @@ fun PrivateOutboxRelayList(
postViewModel: PrivateOutboxRelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
nav: INav,
) {
val newNav = rememberExtendedNav(nav, onClose)
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
Row(verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = FeedPadding,
) {
renderPrivateOutboxItems(feedState, postViewModel, accountViewModel, onClose, nav)
renderPrivateOutboxItems(feedState, postViewModel, accountViewModel, newNav)
}
}
}
@@ -55,18 +58,15 @@ fun LazyListScope.renderPrivateOutboxItems(
feedState: List<BasicRelaySetupInfo>,
postViewModel: PrivateOutboxRelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
nav: INav,
) {
itemsIndexed(feedState, key = { _, item -> "Outbox" + item.url }) { index, item ->
BasicRelaySetupInfoDialog(
item,
onDelete = { postViewModel.deleteRelay(item) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
nav,
)
}
item {

View File

@@ -55,6 +55,8 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.timeAgo
@@ -79,8 +81,10 @@ fun RelayInformationDialog(
relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo,
relayInfo: Nip11RelayInformation,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
val newNav = rememberExtendedNav(nav, onClose)
val messages =
remember(relayBriefInfo) {
RelayStats
@@ -152,10 +156,7 @@ fun RelayInformationDialog(
Section(stringRes(R.string.owner))
relayInfo.pubkey?.let {
DisplayOwnerInformation(it, accountViewModel) {
onClose()
nav(it)
}
DisplayOwnerInformation(it, accountViewModel, newNav)
}
}
item {
@@ -300,7 +301,7 @@ fun RelayInformationDialog(
backgroundColor = color,
id = msg.hashCode().toString(),
accountViewModel = accountViewModel,
nav = nav,
nav = newNav,
)
}
}
@@ -353,7 +354,7 @@ private fun DisplaySoftwareInformation(relayInfo: Nip11RelayInformation) {
private fun DisplayOwnerInformation(
userHex: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
LoadUser(baseUserHex = userHex, accountViewModel) {
CrossfadeIfEnabled(it, accountViewModel = accountViewModel) {

View File

@@ -29,6 +29,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@@ -38,15 +40,17 @@ fun SearchRelayList(
postViewModel: SearchRelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
nav: INav,
) {
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
val newNav = rememberExtendedNav(nav, onClose)
Row(verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = FeedPadding,
) {
renderSearchItems(feedState, postViewModel, accountViewModel, onClose, nav)
renderSearchItems(feedState, postViewModel, accountViewModel, newNav)
}
}
}
@@ -55,18 +59,15 @@ fun LazyListScope.renderSearchItems(
feedState: List<BasicRelaySetupInfo>,
postViewModel: SearchRelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
nav: INav,
) {
itemsIndexed(feedState, key = { _, item -> "Search" + item.url }) { index, item ->
BasicRelaySetupInfoDialog(
item,
onDelete = { postViewModel.deleteRelay(item) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
nav,
)
}
item {

View File

@@ -1,171 +0,0 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.buttons
import android.Manifest
import android.net.Uri
import android.os.Build
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.GallerySelect
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun NewImageButton(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
navScrollToTop: (Route, Boolean) -> Unit,
) {
var wantsToPost by remember { mutableStateOf(false) }
var pickedURI by remember { mutableStateOf<Uri?>(null) }
val scope = rememberCoroutineScope()
val postViewModel: NewMediaModel = viewModel()
postViewModel.onceUploaded {
scope.launch(Dispatchers.Default) {
delay(500)
withContext(Dispatchers.Main) { navScrollToTop(Route.Video, true) }
}
}
if (wantsToPost) {
val cameraPermissionState =
rememberPermissionState(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
},
)
if (cameraPermissionState.status.isGranted) {
var showGallerySelect by remember { mutableStateOf(false) }
if (showGallerySelect) {
GallerySelect(
onImageUri = { uri ->
wantsToPost = false
showGallerySelect = false
pickedURI = uri
},
)
}
showGallerySelect = true
} else {
LaunchedEffect(key1 = accountViewModel) { cameraPermissionState.launchPermissionRequest() }
}
}
pickedURI?.let {
NewMediaView(
uri = it,
onClose = { pickedURI = null },
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
}
if (postViewModel.isUploadingImage) {
ShowProgress(postViewModel)
} else {
FloatingActionButton(
onClick = { wantsToPost = true },
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
painter = painterResource(R.drawable.ic_compose),
contentDescription = stringRes(id = R.string.new_short),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
}
}
}
@Composable
private fun ShowProgress(postViewModel: NewMediaModel) {
Box(Modifier.size(55.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(
progress =
animateFloatAsState(
targetValue = postViewModel.uploadingPercentage.value,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
).value,
modifier =
Size55Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.background),
strokeWidth = 5.dp,
)
postViewModel.uploadingDescription.value?.let {
Text(
it,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 10.sp,
textAlign = TextAlign.Center,
)
}
}
}

View File

@@ -85,15 +85,14 @@ fun CashuPreview(
cashutoken: String,
accountViewModel: AccountViewModel,
) {
@Suppress("ProduceStateDoesNotAssignValue")
val cashuData by produceState(
initialValue = CachedCashuProcessor.cached(cashutoken),
key1 = cashutoken,
) {
withContext(Dispatchers.IO) {
val newToken = CachedCashuProcessor.parse(cashutoken)
if (value != newToken) {
value = newToken
}
val newToken = withContext(Dispatchers.Default) { CachedCashuProcessor.parse(cashutoken) }
if (value != newToken) {
value = newToken
}
}
@@ -116,7 +115,11 @@ fun CashuPreview(
accountViewModel: AccountViewModel,
) {
tokens.forEach {
CashuPreviewNew(it, accountViewModel::meltCashu, accountViewModel::toast)
CashuPreviewNew(
it,
accountViewModel::meltCashu,
accountViewModel::toast,
)
}
}

View File

@@ -25,13 +25,12 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import com.vitorpamplona.amethyst.ui.theme.Size24dp
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
@Composable
fun ClickableBox(
@@ -43,7 +42,7 @@ fun ClickableBox(
modifier.clickable(
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, radius = Size24dp),
indication = ripple24dp,
onClick = onClick,
),
contentAlignment = Alignment.Center,
@@ -64,7 +63,7 @@ fun ClickableBox(
modifier.combinedClickable(
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, radius = Size24dp),
indication = ripple24dp,
onClick = onClick,
onLongClick = onLongClick,
),

View File

@@ -26,6 +26,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.AnnotatedString
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -34,13 +35,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
fun ClickableNoteTag(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
val route = routeFor(baseNote, accountViewModel.userProfile())
ClickableText(
text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"),
onClick = { nav("Note/${baseNote.idHex}") },
onClick = { nav.nav("Note/${baseNote.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary),
)
}

View File

@@ -59,6 +59,7 @@ import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.njumpLink
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -78,7 +79,7 @@ fun ClickableRoute(
word: String,
nip19: Nip19Bech32.ParseReturn,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
when (val entity = nip19.entity) {
is Nip19Bech32.NPub -> DisplayUser(entity.hex, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav)
@@ -122,7 +123,7 @@ private fun LoadAndDisplayEvent(
event: Event,
additionalChars: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
LoadOrCreateNote(event, accountViewModel) {
if (it != null) {
@@ -150,7 +151,7 @@ fun DisplayEvent(
nip19: String,
additionalChars: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
LoadNote(hex, accountViewModel) {
if (it != null) {
@@ -178,7 +179,7 @@ private fun DisplayNoteLink(
kind: Int?,
addedCharts: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
val noteState by it.live().metadata.observeAsState()
@@ -233,7 +234,7 @@ private fun DisplayAddress(
originalNip19: String,
additionalChars: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.atag)) }
@@ -277,7 +278,7 @@ public fun DisplayUser(
originalNip19: String,
additionalChars: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
var userBase by
remember(userHex) {
@@ -312,7 +313,7 @@ public fun DisplayUser(
public fun RenderUserAsClickableText(
baseUser: User,
additionalChars: String?,
nav: (String) -> Unit,
nav: INav,
) {
val userState by baseUser.live().userMetadataInfo.observeAsState()
@@ -335,7 +336,7 @@ fun CreateClickableText(
fontWeight: FontWeight? = null,
fontSize: TextUnit = TextUnit.Unspecified,
route: String,
nav: (String) -> Unit,
nav: INav,
) {
CreateClickableText(
clickablePart,
@@ -344,7 +345,7 @@ fun CreateClickableText(
overrideColor,
fontWeight,
fontSize,
) { nav(route) }
) { nav.nav(route) }
}
@Composable
@@ -627,7 +628,7 @@ fun CreateClickableTextWithEmoji(
fontWeight: FontWeight = FontWeight.Normal,
fontSize: TextUnit = TextUnit.Unspecified,
route: String,
nav: (String) -> Unit,
nav: INav,
tags: ImmutableListOfLists<String>?,
) {
CustomEmojiChecker(
@@ -658,7 +659,7 @@ fun CreateClickableTextWithEmoji(
suffix,
nonClickablePartStyle,
) {
nav(route)
nav.nav(route)
}
},
)

View File

@@ -29,11 +29,12 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.text.AnnotatedString
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.INav
@Composable
fun ClickableUserTag(
user: User,
nav: (String) -> Unit,
nav: INav,
) {
val route = remember { "User/${user.pubkeyHex}" }
@@ -44,7 +45,7 @@ fun ClickableUserTag(
ClickableText(
text = userName,
onClick = { nav(route) },
onClick = { nav.nav(route) },
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary),
)
}

View File

@@ -0,0 +1,73 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ResourceToastMsg
import com.vitorpamplona.amethyst.ui.screen.loggedIn.StringToastMsg
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThrowableToastMsg
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun DisplayErrorMessages(accountViewModel: AccountViewModel) {
val openDialogMsg = accountViewModel.toasts.collectAsStateWithLifecycle(null)
openDialogMsg.value?.let { obj ->
when (obj) {
is ResourceToastMsg ->
if (obj.params != null) {
InformationDialog(
stringRes(obj.titleResId),
stringRes(obj.resourceId, *obj.params),
) {
accountViewModel.clearToasts()
}
} else {
InformationDialog(
stringRes(obj.titleResId),
stringRes(obj.resourceId),
) {
accountViewModel.clearToasts()
}
}
is StringToastMsg ->
InformationDialog(
obj.title,
obj.msg,
) {
accountViewModel.clearToasts()
}
is ThrowableToastMsg ->
InformationDialog(
stringRes(obj.titleResId),
obj.msg,
obj.throwable,
) {
accountViewModel.clearToasts()
}
}
}
}

View File

@@ -0,0 +1,54 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.NotifyRequestDialog
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
@Composable
fun DisplayNotifyMessages(
accountViewModel: AccountViewModel,
nav: INav,
) {
val openDialogMsg =
accountViewModel.account.transientPaymentRequests.collectAsStateWithLifecycle(null)
openDialogMsg.value?.firstOrNull()?.let { request ->
NotifyRequestDialog(
title =
stringRes(
id = R.string.payment_required_title,
RelayUrlFormatter.displayUrl(request.relayUrl),
),
textContent = request.description,
accountViewModel = accountViewModel,
nav = nav,
) {
accountViewModel.dismissPaymentRequest(request)
}
}
}

View File

@@ -42,6 +42,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.ExpandableTextCutOffCalculator
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.getGradient
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -66,7 +67,7 @@ fun ExpandableRichTextViewer(
id: String,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
var showFullText by
remember {

View File

@@ -69,13 +69,12 @@ fun LoadValueFromInvoice(
lnbcWord: String,
inner: @Composable (invoiceAmount: InvoiceAmount?) -> Unit,
) {
@Suppress("ProduceStateDoesNotAssignValue")
val lnInvoice by
produceState(initialValue = CachedLnInvoiceParser.cached(lnbcWord), key1 = lnbcWord) {
withContext(Dispatchers.IO) {
val newLnInvoice = CachedLnInvoiceParser.parse(lnbcWord)
if (value != newLnInvoice) {
value = newLnInvoice
}
val newLnInvoice = withContext(Dispatchers.Default) { CachedLnInvoiceParser.parse(lnbcWord) }
if (value != newLnInvoice) {
value = newLnInvoice
}
}

View File

@@ -41,6 +41,7 @@ fun LoadUrlPreview(
if (!accountViewModel.settings.showUrlPreview.value) {
ClickableUrl(urlText, url)
} else {
@Suppress("ProduceStateDoesNotAssignValue")
val urlPreviewState by
produceState(
initialValue = UrlCachedPreviewer.cache.get(url) ?: UrlPreviewState.Loading,

View File

@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.webkit.MimeTypeMap
import android.util.Log
import androidx.core.net.toUri
import com.abedelazizshe.lightcompressorlibrary.CompressionListener
import com.abedelazizshe.lightcompressorlibrary.VideoCompressor
@@ -32,11 +32,11 @@ import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfigu
import com.abedelazizshe.lightcompressorlibrary.config.Configuration
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils
import id.zelory.compressor.Compressor
import id.zelory.compressor.constraint.default
import kotlinx.coroutines.CancellationException
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
class MediaCompressor {
@@ -46,120 +46,167 @@ class MediaCompressor {
applicationContext: Context,
onReady: (Uri, String?, Long?) -> Unit,
onError: (Int) -> Unit,
mediaQuality: CompressorQuality,
) {
// Skip compression if user selected uncompressed
if (mediaQuality == CompressorQuality.UNCOMPRESSED) {
Log.d("MediaCompressor", "UNCOMPRESSED quality selected, skipping compression.")
onReady(uri, contentType, null)
return
}
checkNotInMainThread()
if (contentType?.startsWith("video", true) == true) {
VideoCompressor.start(
// => This is required
context = applicationContext,
// => Source can be provided as content uris
uris = listOf(uri),
isStreamable = false,
// THIS STORAGE
// sharedStorageConfiguration = SharedStorageConfiguration(
// saveAt = SaveLocation.movies, // => default is movies
// videoName = "compressed_video" // => required name
// ),
// OR AND NOT BOTH
appSpecificStorageConfiguration = AppSpecificStorageConfiguration(),
configureWith =
Configuration(
quality = VideoQuality.MEDIUM,
// => required name
videoNames = listOf(UUID.randomUUID().toString()),
),
listener =
object : CompressionListener {
override fun onProgress(
index: Int,
percent: Float,
) {}
// branch into compression based on content type
when {
contentType?.startsWith("video", ignoreCase = true) == true ->
compressVideo(uri, contentType, applicationContext, onReady, onError, mediaQuality)
contentType?.startsWith("image", ignoreCase = true) == true &&
!contentType.contains("gif") &&
!contentType.contains("svg") ->
compressImage(uri, contentType, applicationContext, onReady, onError, mediaQuality)
else -> onReady(uri, contentType, null)
}
}
override fun onStart(index: Int) {
// Compression start
}
override fun onSuccess(
index: Int,
size: Long,
path: String?,
) {
if (path != null) {
onReady(Uri.fromFile(File(path)), contentType, size)
} else {
onError(R.string.compression_returned_null)
}
}
override fun onFailure(
index: Int,
failureMessage: String,
) {
// keeps going with original video
onReady(uri, contentType, null)
}
override fun onCancelled(index: Int) {
onError(R.string.compression_cancelled)
}
},
)
} else if (
contentType?.startsWith("image", true) == true &&
!contentType.contains("gif") &&
!contentType.contains("svg")
) {
try {
val compressedImageFile =
Compressor.compress(applicationContext, from(uri, contentType, applicationContext)) {
default(width = 640, format = Bitmap.CompressFormat.JPEG)
}
onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length())
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
onReady(uri, contentType, null)
private fun compressVideo(
uri: Uri,
contentType: String?,
applicationContext: Context,
onReady: (Uri, String?, Long?) -> Unit,
onError: (Int) -> Unit,
mediaQuality: CompressorQuality,
) {
val videoQuality =
when (mediaQuality) {
CompressorQuality.VERY_LOW -> VideoQuality.VERY_LOW
CompressorQuality.LOW -> VideoQuality.LOW
CompressorQuality.MEDIUM -> VideoQuality.MEDIUM
CompressorQuality.HIGH -> VideoQuality.HIGH
CompressorQuality.VERY_HIGH -> VideoQuality.VERY_HIGH
else -> VideoQuality.MEDIUM
}
} else {
Log.d("MediaCompressor", "Using video compression $mediaQuality")
VideoCompressor.start(
// => This is required
context = applicationContext,
// => Source can be provided as content uris
uris = listOf(uri),
isStreamable = false,
// THIS STORAGE
// sharedStorageConfiguration = SharedStorageConfiguration(
// saveAt = SaveLocation.movies, // => default is movies
// videoName = "compressed_video" // => required name
// ),
// OR AND NOT BOTH
appSpecificStorageConfiguration = AppSpecificStorageConfiguration(),
configureWith =
Configuration(
quality = videoQuality,
// => required name
videoNames = listOf(UUID.randomUUID().toString()),
),
listener =
object : CompressionListener {
override fun onProgress(
index: Int,
percent: Float,
) {
}
override fun onStart(index: Int) {}
override fun onSuccess(
index: Int,
size: Long,
path: String?,
) {
if (path != null) {
Log.d("MediaCompressor", "Video compression success. Compressed size [$size]")
onReady(Uri.fromFile(File(path)), contentType, size)
} else {
Log.d("MediaCompressor", "Video compression successful, but returned null path")
onError(R.string.compression_returned_null)
}
}
override fun onFailure(
index: Int,
failureMessage: String,
) {
Log.d("MediaCompressor", "Video compression failed: $failureMessage")
// keeps going with original video
onReady(uri, contentType, null)
}
override fun onCancelled(index: Int) {
onError(R.string.compression_cancelled)
}
},
)
}
private suspend fun compressImage(
uri: Uri,
contentType: String?,
context: Context,
onReady: (Uri, String?, Long?) -> Unit,
onError: (Int) -> Unit,
mediaQuality: CompressorQuality,
) {
val imageQuality =
when (mediaQuality) {
CompressorQuality.VERY_LOW -> 40
CompressorQuality.LOW -> 50
CompressorQuality.MEDIUM -> 60
CompressorQuality.HIGH -> 80
CompressorQuality.VERY_HIGH -> 90
else -> 60
}
try {
Log.d("MediaCompressor", "Using image compression $mediaQuality")
val tempFile = MediaCompressorFileUtils.from(uri, context)
val compressedImageFile =
Compressor.compress(context, tempFile) {
default(width = 640, format = Bitmap.CompressFormat.JPEG, quality = imageQuality)
}
Log.d("MediaCompressor", "Image compression success. Original size [${tempFile.length()}], new size [${compressedImageFile.length()}]")
onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length())
} catch (e: Exception) {
Log.d("MediaCompressor", "Image compression failed: ${e.message}")
if (e is CancellationException) throw e
e.printStackTrace()
onReady(uri, contentType, null)
}
}
fun from(
uri: Uri?,
contentType: String?,
context: Context,
): File {
val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val inputStream = context.contentResolver.openInputStream(uri!!)
val fileName: String = UUID.randomUUID().toString() + ".$extension"
val splitName: Array<String> = splitFileName(fileName)
val tempFile = File.createTempFile(splitName[0], splitName[1])
inputStream?.use { input ->
FileOutputStream(tempFile).use { output ->
val buffer = ByteArray(1024 * 50)
var read: Int = input.read(buffer)
while (read != -1) {
output.write(buffer, 0, read)
read = input.read(buffer)
}
}
fun intToCompressorQuality(mediaQualityFloat: Int): CompressorQuality =
when (mediaQualityFloat) {
0 -> CompressorQuality.LOW
1 -> CompressorQuality.MEDIUM
2 -> CompressorQuality.HIGH
3 -> CompressorQuality.UNCOMPRESSED
else -> CompressorQuality.MEDIUM
}
return tempFile
}
private fun splitFileName(fileName: String): Array<String> {
var name = fileName
var extension = ""
val i = fileName.lastIndexOf(".")
if (i != -1) {
name = fileName.substring(0, i)
extension = fileName.substring(i)
fun compressorQualityToInt(compressorQuality: CompressorQuality): Int =
when (compressorQuality) {
CompressorQuality.LOW -> 0
CompressorQuality.MEDIUM -> 1
CompressorQuality.HIGH -> 2
CompressorQuality.UNCOMPRESSED -> 3
else -> 1
}
return arrayOf(name, extension)
}
}
enum class CompressorQuality {
VERY_LOW,
LOW,
MEDIUM,
HIGH,
VERY_HIGH,
UNCOMPRESSED,
}

View File

@@ -85,6 +85,8 @@ import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -115,7 +117,7 @@ fun RichTextViewer(
backgroundColor: MutableState<Color>,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
Column(modifier = modifier) {
if (remember(content) { isMarkdown(content) }) {
@@ -129,7 +131,7 @@ fun RichTextViewer(
@Preview
@Composable
fun RenderStrangeNamePreview() {
val nav: (String) -> Unit = {}
val nav = EmptyNav
Column(modifier = Modifier.padding(10.dp)) {
RenderRegular(
@@ -152,7 +154,7 @@ fun RenderStrangeNamePreview() {
@Preview
@Composable
fun RenderRegularPreview() {
val nav: (String) -> Unit = {}
val nav = EmptyNav
Column(modifier = Modifier.padding(10.dp)) {
RenderRegular(
@@ -192,7 +194,7 @@ fun RenderRegularPreview() {
@Preview
@Composable
fun RenderRegularPreview2() {
val nav: (String) -> Unit = {}
val nav = EmptyNav
RenderRegular(
"#Amethyst v0.84.1: ncryptsec support (NIP-49)",
EmptyTagList,
@@ -229,7 +231,7 @@ fun RenderRegularPreview3() {
arrayOf("proxy", "https://misskey.io/notes/9q0x6gtdysir03qh", "activitypub"),
),
)
val nav: (String) -> Unit = {}
val nav = EmptyNav
val accountViewModel = mockAccountViewModel()
RenderRegular(
@@ -266,7 +268,7 @@ private fun RenderRegular(
backgroundColor: MutableState<Color>,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
RenderRegular(content, tags, callbackUri) { word, state ->
if (canPreview) {
@@ -359,7 +361,7 @@ private fun RenderWordWithoutPreview(
state: RichTextViewerState,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
when (word) {
// Don't preview Images
@@ -390,7 +392,7 @@ private fun RenderWordWithPreview(
quotesLeft: Int,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
when (word) {
is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
@@ -448,7 +450,7 @@ fun BechLink(
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
val loadedLink by produceCachedState(cache = accountViewModel.bechLinkCache, key = word)
@@ -488,7 +490,7 @@ fun DisplayFullNote(
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
NoteCompose(
baseNote = note,
@@ -510,7 +512,7 @@ fun DisplayFullNote(
@Composable
fun HashTag(
segment: HashTagSegment,
nav: (String) -> Unit,
nav: INav,
) {
val primary = MaterialTheme.colorScheme.primary
val background = MaterialTheme.colorScheme.onBackground
@@ -542,12 +544,12 @@ fun HashTag(
modifier =
remember {
Modifier.clickable {
nav("Hashtag/${segment.hashtag}")
nav.nav("Hashtag/${segment.hashtag}")
}
},
inlineContent =
if (hashtagIcon != null) {
mapOf("inlineContent" to InlineIcon(hashtagIcon))
mapOf("inlineContent" to inlineIcon(hashtagIcon))
} else {
emptyMap()
},
@@ -555,7 +557,7 @@ fun HashTag(
}
@Composable
private fun InlineIcon(hashtagIcon: HashtagIcon) =
private fun inlineIcon(hashtagIcon: HashtagIcon) =
InlineTextContent(inlinePlaceholder) {
Icon(
imageVector = hashtagIcon.icon,
@@ -569,7 +571,7 @@ private fun InlineIcon(hashtagIcon: HashtagIcon) =
fun TagLink(
word: HashIndexUserSegment,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
LoadUser(baseUserHex = word.hex, accountViewModel) {
if (it == null) {
@@ -610,7 +612,7 @@ fun TagLink(
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
LoadNote(baseNoteHex = word.hex, accountViewModel) {
if (it == null) {
@@ -639,7 +641,7 @@ private fun DisplayNoteFromTag(
quotesLeft: Int,
accountViewModel: AccountViewModel,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
nav: INav,
) {
if (canPreview && quotesLeft > 0) {
NoteCompose(
@@ -662,7 +664,7 @@ private fun DisplayNoteFromTag(
private fun DisplayUserFromTag(
baseUser: User,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nav: INav,
) {
val meta by baseUser.live().userMetadataInfo.observeAsState(baseUser.info)

View File

@@ -30,7 +30,6 @@ import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.VideoView
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.LinearEasing
@@ -101,7 +100,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
import com.vitorpamplona.amethyst.service.playback.PlaybackClientController
import com.vitorpamplona.amethyst.ui.actions.ImageSaver
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
import com.vitorpamplona.amethyst.ui.note.LyricsIcon
import com.vitorpamplona.amethyst.ui.note.LyricsOffIcon
@@ -120,6 +119,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
import com.vitorpamplona.ammolite.service.HttpClientManager
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -369,6 +369,7 @@ fun VideoViewInner(
videoUri = videoUri,
defaultToStart = defaultToStart,
nostrUriCallback = nostrUriCallback,
proxyPort = HttpClientManager.getCurrentProxyPort(accountViewModel.account.shouldUseTorForVideoDownload(videoUri)),
) { controller, keepPlaying ->
RenderVideoPlayer(
videoUri = videoUri,
@@ -459,6 +460,7 @@ fun GetMediaItem(
fun GetVideoController(
mediaItem: State<MediaItem>,
videoUri: String,
proxyPort: Int?,
defaultToStart: Boolean = false,
nostrUriCallback: String? = null,
inner: @Composable (controller: MediaController, keepPlaying: MutableState<Boolean>) -> Unit,
@@ -502,6 +504,7 @@ fun GetVideoController(
uid,
videoUri,
nostrUriCallback,
proxyPort,
context,
) {
scope.launch(Dispatchers.Main) {
@@ -590,6 +593,7 @@ fun GetVideoController(
uid,
videoUri,
nostrUriCallback,
proxyPort,
context,
) {
scope.launch(Dispatchers.Main) {
@@ -751,6 +755,7 @@ private fun RenderVideoPlayer(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS)
setBackgroundColor(Color.Transparent.toArgb())
setShutterBackgroundColor(Color.Transparent.toArgb())
controllerAutoShow = false
@@ -826,12 +831,18 @@ private fun RenderVideoPlayer(
keepPlaying.value = newKeepPlaying
}
AnimatedSaveButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size110dp)) { context ->
saveImage(videoUri, mimeType, context, accountViewModel)
}
if (!videoUri.endsWith(".m3u8")) {
AnimatedSaveButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size110dp)) { context ->
saveMediaToGallery(videoUri, mimeType, context, accountViewModel)
}
AnimatedShareButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size165dp)) { popupExpanded, toggle ->
ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, null, null, null, mimeType, toggle)
AnimatedShareButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size165dp)) { popupExpanded, toggle ->
ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, null, null, null, mimeType, toggle)
}
} else {
AnimatedShareButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size110dp)) { popupExpanded, toggle ->
ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, null, null, null, mimeType, toggle)
}
}
} else {
controller.volume = 0f
@@ -1199,21 +1210,22 @@ fun SaveButton(onSaveClick: (localContext: Context) -> Unit) {
}
}
private fun saveImage(
private fun saveMediaToGallery(
videoUri: String?,
mimeType: String?,
localContext: Context,
accountViewModel: AccountViewModel,
) {
ImageSaver.saveImage(
MediaSaverToDisk.saveDownloadingIfNeeded(
videoUri = videoUri,
forceProxy = accountViewModel.account.shouldUseTorForVideoDownload(),
mimeType = mimeType,
localContext = localContext,
onSuccess = {
accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery)
accountViewModel.toast(R.string.video_saved_to_the_gallery, R.string.video_saved_to_the_gallery)
},
onError = {
accountViewModel.toast(R.string.failed_to_save_the_image, null, it)
accountViewModel.toast(R.string.failed_to_save_the_video, null, it)
},
)
}

View File

@@ -24,7 +24,6 @@ import android.Manifest
import android.content.Context
import android.os.Build
import android.view.View
import android.view.WindowInsets
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.compose.animation.AnimatedVisibility
@@ -54,7 +53,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.SideEffect
@@ -82,7 +80,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaPreloadedContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.ui.actions.ImageSaver
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
@@ -144,22 +142,6 @@ fun ZoomableImageDialog(
}
}
DisposableEffect(key1 = Unit) {
if (Build.VERSION.SDK_INT >= 30) {
view.windowInsetsController?.hide(
WindowInsets.Type.systemBars(),
)
}
onDispose {
if (Build.VERSION.SDK_INT >= 30) {
view.windowInsetsController?.show(
WindowInsets.Type.systemBars(),
)
}
}
}
Surface(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
DialogContent(allImages, imageUrl, onDismiss, accountViewModel)
@@ -273,67 +255,82 @@ private fun DialogContent(
ShareImageAction(accountViewModel = accountViewModel, popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false })
}
val localContext = LocalContext.current
if (myContent !is MediaUrlContent || !myContent.url.endsWith(".m3u8")) {
val localContext = LocalContext.current
val writeStoragePermissionState =
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
if (isGranted) {
saveImage(myContent, localContext, accountViewModel)
val writeStoragePermissionState =
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
if (isGranted) {
saveMediaToGallery(myContent, localContext, accountViewModel)
}
}
OutlinedButton(
onClick = {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
writeStoragePermissionState.status.isGranted
) {
saveMediaToGallery(myContent, localContext, accountViewModel)
} else {
writeStoragePermissionState.launchPermissionRequest()
}
},
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
) {
Icon(
imageVector = Icons.Default.Download,
modifier = Size20Modifier,
contentDescription = stringRes(R.string.save_to_gallery),
)
}
OutlinedButton(
onClick = {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
writeStoragePermissionState.status.isGranted
) {
saveImage(myContent, localContext, accountViewModel)
} else {
writeStoragePermissionState.launchPermissionRequest()
}
},
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
) {
Icon(
imageVector = Icons.Default.Download,
modifier = Size20Modifier,
contentDescription = stringRes(R.string.save_to_gallery),
)
}
}
}
}
}
private fun saveImage(
private fun saveMediaToGallery(
content: BaseMediaContent,
localContext: Context,
accountViewModel: AccountViewModel,
) {
val isImage = content is MediaUrlImage && content is MediaLocalImage
val success = if (isImage) R.string.image_saved_to_the_gallery else R.string.video_saved_to_the_gallery
val failure = if (isImage) R.string.failed_to_save_the_image else R.string.failed_to_save_the_video
if (content is MediaUrlContent) {
ImageSaver.saveImage(
val useTor =
if (isImage) {
accountViewModel.account.shouldUseTorForImageDownload()
} else {
accountViewModel.account.shouldUseTorForVideoDownload()
}
MediaSaverToDisk.downloadAndSave(
content.url,
forceProxy = useTor,
localContext,
onSuccess = {
accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery)
accountViewModel.toast(success, success)
},
onError = {
accountViewModel.toast(R.string.failed_to_save_the_image, null, it)
accountViewModel.toast(failure, null, it)
},
)
} else if (content is MediaPreloadedContent) {
content.localFile?.let {
ImageSaver.saveImage(
MediaSaverToDisk.save(
it,
content.mimeType,
localContext,
onSuccess = {
accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery)
accountViewModel.toast(success, success)
},
onError = {
accountViewModel.toast(R.string.failed_to_save_the_image, null, it)
accountViewModel.toast(failure, null, it)
},
)
}

View File

@@ -126,6 +126,7 @@ fun ZoomableContentView(
val isLandscapeMode = DeviceUtils.isLandscapeMetric(LocalContext.current)
val isFoldableOrLarge = DeviceUtils.windowIsLarge(windowSize = currentWindowSize, isInLandscapeMode = isLandscapeMode)
val isOrientationLocked = DeviceUtils.screenOrientationIsLocked(LocalContext.current)
val contentScale =
if (isFiniteHeight) {
@@ -160,7 +161,7 @@ fun ZoomableContentView(
nostrUriCallback = content.uri,
onDialog = {
dialogOpen = true
// if (!isFoldableOrLarge) {
// if (!isFoldableOrLarge && !isOrientationLocked) {
// DeviceUtils.changeDeviceOrientation(isLandscapeMode, activity)
// }
},
@@ -200,7 +201,7 @@ fun ZoomableContentView(
images,
onDismiss = {
dialogOpen = false
// if (!isFoldableOrLarge) DeviceUtils.changeDeviceOrientation(isLandscapeMode, activity)
// if (!isFoldableOrLarge && !isOrientationLocked) DeviceUtils.changeDeviceOrientation(isLandscapeMode, activity)
},
accountViewModel,
)

View File

@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.components.DisplayFullNote
import com.vitorpamplona.amethyst.ui.components.DisplayUser
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadedBechLink
import com.vitorpamplona.amethyst.ui.theme.Font17SP
@@ -59,7 +60,7 @@ class MarkdownMediaRenderer(
val backgroundColor: MutableState<Color>,
val callbackUri: String? = null,
val accountViewModel: AccountViewModel,
val nav: (String) -> Unit,
val nav: INav,
) : MediaRenderer {
val parser = RichTextParser()

Some files were not shown because too many files have changed in this diff Show More