Compare commits

..
279 Commits
Author SHA1 Message Date
Vitor Pamplona b4eba2da02 0.92.2 2024-10-11 19:35:52 -04:00
Vitor PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 KasparandGitHub 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 KasparandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 KasparandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 KasparandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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
greenart7c3andGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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
greenart7c3andgreenart7c3 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 3e23d6e526 Merge pull request #1051 from davotoula/configurable-media-compression
Configurable media compression
2024-09-05 12:07:33 -04:00
David KasparandGitHub 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 KasparandGitHub 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 PamplonaandGitHub 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 KasparandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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
Vitor Pamplona d5a98db67c v0.90.6 2024-09-02 10:05:07 -04:00
Vitor Pamplona 72412b9d03 Merge 2024-09-02 10:04:38 -04:00
Vitor Pamplona b5b90c80e9 Revert "v0.90.6"
This reverts commit 2ca5186aef.
2024-09-02 10:03:15 -04:00
Vitor PamplonaandGitHub c1077f689d Merge pull request #1046 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-02 10:02:32 -04:00
Crowdin Bot 8b26a84564 New Crowdin translations by GitHub Action 2024-09-02 14:00:35 +00:00
Vitor Pamplona 2ca5186aef v0.90.6 2024-09-02 10:00:28 -04:00
Vitor PamplonaandGitHub 064e211154 Merge pull request #1045 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-02 09:59:23 -04:00
Vitor PamplonaandGitHub 2e8ab35f05 Merge pull request #1042 from greenart7c3/clickable_url
Long press to copy relay url
2024-09-02 09:59:14 -04:00
Crowdin Bot df429b976f New Crowdin translations by GitHub Action 2024-09-02 13:59:11 +00:00
Vitor PamplonaandGitHub d40995c338 Merge pull request #1039 from davotoula/1038-Profile-Lightning-payment-is-100x-too-large
millisats were incorrectly multiplied by 1000
2024-09-02 09:57:45 -04:00
greenart7c3 bba7c257da long press to copy relay url in the relay icons and relay row 2024-09-02 09:52:39 -03:00
greenart7c3 a03e02bfeb Add a button to take a picture from camera 2024-09-02 08:42:46 -03:00
David Kaspar a4a753e7b6 Rename amount to amountInMillisats to give visual clue to calling classes that millisats should be used 2024-09-02 12:26:26 +02:00
greenart7c3 53acbd8606 Long press to copy relay url 2024-09-02 07:15:59 -03:00
Vitor Pamplona f89c1ff391 v0.90.5 2024-09-01 15:07:07 -04:00
Vitor PamplonaandGitHub 471ee21749 Merge pull request #1041 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-01 12:38:26 -04:00
Crowdin Bot 24f7e043af New Crowdin translations by GitHub Action 2024-09-01 16:37:46 +00:00
Vitor PamplonaandGitHub 34e82dab8b Merge pull request #1040 from davotoula/1037-tor-setting-reset-to-off-after-switching-away
1037 tor setting reset to off after switching away
2024-09-01 12:36:36 -04:00
David Kaspar 9fa1b8c541 Close "needsPassword.value" if statement earlier to make Orbot checkbox visible again 2024-09-01 17:09:03 +02:00
David Kaspar 1e61162f73 change the logic for saving of proxy state 2024-09-01 17:05:20 +02:00
David Kaspar 941266365e millisats were incorrectly multiplied by 1000 2024-08-31 19:24:12 +01:00
Vitor Pamplona b1f1466e6b v0.90.4 2024-08-30 18:43:34 -04:00
Vitor Pamplona 55d53dbc4f Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-30 18:42:50 -04:00
Vitor Pamplona aeaddf722b - Fixes caching bug of NIP-96 servers
- Allow NIP-96 setups with relative URLs
- Displays error messages if the server has sent in the body
- Adds a test case for both
2024-08-30 18:42:41 -04:00
Vitor PamplonaandGitHub b733fbce35 Merge pull request #1036 from Dawn11111111/patch-1
Typo Fix on Lnaddress TextField
2024-08-30 12:05:00 -04:00
DawnandGitHub 93e9e23730 Typo Fix on Lnaddress TextField
Removed 'i' from lightining :)
2024-08-30 09:45:13 -06:00
Vitor Pamplona 388ccdbe75 Speeds up the filter for NWC zap payments. 2024-08-29 12:32:34 -04:00
Vitor Pamplona 255f464046 Basic code for dynamic limits 2024-08-29 10:29:46 -04:00
Vitor Pamplona 8be74649ce Protects against empty nip04 content 2024-08-28 17:59:59 -04:00
Vitor Pamplona b5709f9527 Adds basic support for NIP-46 events. 2024-08-28 17:39:25 -04:00
Vitor Pamplona 7d9efcd1d9 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-28 16:07:47 -04:00
Vitor PamplonaandGitHub d44f5dad18 Merge pull request #1035 from greenart7c3/main
Show relay ping with the relay icon
2024-08-28 16:06:50 -04:00
greenart7c3 06f37ab81d Fix relay icons when using complete ui 2024-08-28 16:20:55 -03:00
Vitor Pamplona cb4a73bb9c Enables decryption by nip04 and nip44 on NostrWalletConnect objects, NIP-51 lists and NIP-04 messages 2024-08-28 15:20:25 -04:00
greenart7c3 efde14a348 Show relay ping with the relay icon 2024-08-28 16:17:08 -03:00
Vitor Pamplona 0f86e3d8fb Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-28 09:20:48 -04:00
Vitor Pamplona 1407850e81 Fixes push registration with empty token ids 2024-08-28 09:20:40 -04:00
Vitor PamplonaandGitHub d9a87f6e7c Merge pull request #1034 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-28 08:33:37 -04:00
Crowdin Bot 5e0a49f225 New Crowdin translations by GitHub Action 2024-08-28 12:31:37 +00:00
Vitor Pamplona 3ebaf2bf1e Fixes ZapTheDevs card not disappearing after the donation. 2024-08-28 08:29:53 -04:00
Vitor PamplonaandGitHub 9ef0ceed9d Merge pull request #1033 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-27 17:49:17 -04:00
Crowdin Bot caa3760fd3 New Crowdin translations by GitHub Action 2024-08-27 21:46:25 +00:00
Vitor Pamplona 0b2a9e67c0 v0.90.3 2024-08-27 17:44:44 -04:00
Vitor Pamplona 931081c5e7 Exposes decryption crashing because it is used by Notification Services to check which account can decrypt a GiftWrap. 2024-08-27 17:43:12 -04:00
Vitor Pamplona f7237b5666 Improves logs on new Notifications 2024-08-27 17:42:18 -04:00
Vitor Pamplona d851637ea2 Fixes byte to string decoder for UnifiedPush 2024-08-27 17:41:56 -04:00
Vitor Pamplona 2d39b4edfe Only register for endpoints if it has changed from the one in memory 2024-08-27 17:41:37 -04:00
Vitor Pamplona 5001c5bef8 There is no need to extend onReceive 2024-08-27 17:40:53 -04:00
Vitor Pamplona 6ac1db9fc8 correctly passes the endpoint to remove it 2024-08-27 17:40:35 -04:00
Vitor Pamplona 52f33d79e0 Correctly registering the distributor at startup 2024-08-27 17:38:14 -04:00
Vitor Pamplona 1a9d91cdc0 Fixes correct saving of UnifiedPush endpoints 2024-08-27 17:37:53 -04:00
Vitor Pamplona 2f444e6ec5 Moves ChatMessage components to the chatroom package 2024-08-27 16:03:15 -04:00
Vitor Pamplona 4665c488d5 Adds a small border between the author and the message of a chat bubble 2024-08-27 15:57:56 -04:00
377 changed files with 12977 additions and 6496 deletions
+4 -3
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.
+2 -2
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
View File
@@ -13,6 +13,7 @@
/.idea/ktfmt.xml
/.idea/studiobot.xml
/.idea/other.xml
/.idea/runConfigurations.xml
.DS_Store
/build
/captures
+28
View File
@@ -1,6 +1,34 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<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" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
+13 -3
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 399
versionName "0.90.2"
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
}
@@ -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
@@ -188,6 +190,12 @@ class ImageUploadTesting {
testBase(Nip96MediaServers.ServerName("nostpic.com", "https://nostpic.com"))
}
@Test(expected = RuntimeException::class)
fun testSprovoostNl() =
runBlocking {
testBase(Nip96MediaServers.ServerName("sprovoost.nl", "https://img.sprovoost.nl/"))
}
@Test()
@Ignore("Not Working anymore")
fun testNostrOnch() =
@@ -46,7 +46,7 @@ class OkHttpOtsTest {
@Before
fun setup() {
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(), OkHttpCalendarBuilder())
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(forceProxy = { false }), OkHttpCalendarBuilder(forceProxy = { false }))
}
@Test
@@ -38,13 +38,11 @@ interface PushDistributorActions {
}
object PushDistributorHandler : PushDistributorActions {
private val appContext = Amethyst.instance.applicationContext
private val unifiedPush: UnifiedPush = UnifiedPush
private var endpointInternal = ""
val endpoint = endpointInternal
fun getSavedEndpoint() = endpoint
fun getSavedEndpoint() = endpointInternal
fun setEndpoint(newEndpoint: String) {
endpointInternal = newEndpoint
@@ -55,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()
@@ -69,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
}
@@ -88,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) {
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.util.Log
import android.util.LruCache
import androidx.core.content.ContextCompat
@@ -52,11 +51,13 @@ class PushMessageReceiver : MessagingReceiver() {
message: ByteArray,
instance: String,
) {
val messageStr = String(message)
Log.d(TAG, "New message ${message.decodeToString()} for Instance: $instance")
val messageStr = message.decodeToString()
Log.d(TAG, "New message $messageStr for Instance: $instance")
scope.launch {
try {
parseMessage(messageStr)?.let { receiveIfNew(it) }
parseMessage(messageStr)?.let {
receiveIfNew(it)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.d(TAG, "Message could not be parsed: ${e.message}")
@@ -83,26 +84,20 @@ class PushMessageReceiver : MessagingReceiver() {
endpoint: String,
instance: String,
) {
Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance")
val sanitizedEndpoint = endpoint.dropLast(5)
pushHandler.setEndpoint(sanitizedEndpoint)
scope.launch(Dispatchers.IO) {
RegisterAccounts(LocalPreferences.allSavedAccounts()).go(sanitizedEndpoint)
notificationManager().getOrCreateZapChannel(appContext)
notificationManager().getOrCreateDMChannel(appContext)
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)
scope.launch(Dispatchers.IO) {
RegisterAccounts(LocalPreferences.allSavedAccounts()).go(sanitizedEndpoint)
notificationManager().getOrCreateZapChannel(appContext)
notificationManager().getOrCreateDMChannel(appContext)
}
} else {
Log.d(TAG, "Same endpoint provided:- $endpoint for Instance: $instance $sanitizedEndpoint")
}
}
override fun onReceive(
context: Context,
intent: Intent,
) {
val intentData = intent.dataString
val intentAction = intent.action.toString()
Log.d(TAG, "Intent Data:- $intentData Intent Action: $intentAction")
super.onReceive(context, intent)
}
override fun onRegistrationFailed(
context: Context,
instance: String,
@@ -115,7 +110,7 @@ class PushMessageReceiver : MessagingReceiver() {
context: Context,
instance: String,
) {
val removedEndpoint = pushHandler.endpoint
val removedEndpoint = pushHandler.getSavedEndpoint()
Log.d(TAG, "Endpoint: $removedEndpoint removed for Instance: $instance")
Log.d(TAG, "App is unregistered. ")
pushHandler.forceRemoveDistributor(context)
@@ -34,6 +34,9 @@ object PushNotificationUtils {
}
try {
if (pushHandler.savedDistributorExists()) {
val currentDistributor = PushDistributorHandler.getSavedDistributor()
PushDistributorHandler.saveDistributor(currentDistributor)
RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint())
}
} catch (e: Exception) {
@@ -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
@@ -150,9 +150,6 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
)
}
}
} else {
val currentDistributor = PushDistributorHandler.getSavedDistributor()
PushDistributorHandler.saveDistributor(currentDistributor)
}
}
}
@@ -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,
+13
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>
@@ -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(
@@ -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 ")
}
}
@@ -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,
@@ -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)
@@ -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 },
)
}
@@ -1188,11 +1290,22 @@ class Account(
replyingToHex = event.id,
authSigner = signer,
)
wcListener.start()
wcListener.startSync()
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 {
@@ -23,17 +23,16 @@ package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.compose.runtime.Stable
import androidx.core.os.ConfigurationCompat
import com.vitorpamplona.amethyst.BuildConfig
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
@@ -52,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 =
@@ -115,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(),
@@ -140,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,
@@ -151,6 +149,10 @@ class AccountSettings(
) {
val saveable = MutableStateFlow(AccountSettingsUpdater(this))
class AccountSettingsUpdater(
val accountSettings: AccountSettings,
)
fun saveAccountSettings() {
saveable.update { AccountSettingsUpdater(this) }
}
@@ -163,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))
}
}
@@ -245,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()
}
}
@@ -288,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)
}
}
}
@@ -426,7 +431,7 @@ class AccountSettings(
fun markDonatedInThisVersion(versionName: String): Boolean {
if (!hasDonatedInVersion.value.contains(versionName)) {
hasDonatedInVersion.update {
it + BuildConfig.VERSION_NAME
it + versionName
}
saveAccountSettings()
return true
@@ -519,7 +524,3 @@ class AccountSettings(
false
}
}
class AccountSettingsUpdater(
val accountSettings: AccountSettings,
)
@@ -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)
}
}
@@ -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())
@@ -1664,7 +1665,10 @@ object LocalCache {
@Suppress("UNUSED_PARAMETER")
fun consume(event: ChannelMuteUserEvent) {}
fun consume(event: LnZapEvent) {
fun consume(
event: LnZapEvent,
relay: Relay?,
) {
val note = getOrCreateNote(event.id)
// Already processed this event.
if (note.event != null) return
@@ -1873,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()) {
@@ -2152,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
}
@@ -2693,7 +2697,7 @@ object LocalCache {
event.zapRequest?.let {
// must have a valid request
verifyAndConsume(it, relay)
consume(event)
consume(event, relay)
}
}
is LnZapRequestEvent -> consume(event)
@@ -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,
@@ -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 ||
@@ -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)
},
)
}
}
@@ -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
@@ -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)
@@ -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)
}
@@ -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 {
@@ -27,6 +27,8 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.ammolite.service.HttpClientManager
import kotlinx.coroutines.CancellationException
import okhttp3.Request
import java.net.URI
import java.net.URL
object Nip96MediaServers {
val DEFAULT =
@@ -45,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
}
@@ -76,13 +81,32 @@ class Nip96Retriever {
val mediaTransformations: Map<MimeType, Array<String>> = emptyMap(),
)
fun parse(body: String): ServerInfo {
fun parse(
baseUrl: String,
body: String,
): ServerInfo {
val mapper =
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, ServerInfo::class.java)
val serverInfo = mapper.readValue(body, ServerInfo::class.java)
return serverInfo.copy(
apiUrl = makeAbsoluteIfRelativeUrl(baseUrl, serverInfo.apiUrl),
downloadUrl = serverInfo.downloadUrl?.let { makeAbsoluteIfRelativeUrl(baseUrl, it) },
delegatedToUrl = serverInfo.delegatedToUrl?.let { makeAbsoluteIfRelativeUrl(baseUrl, it) },
tosUrl = serverInfo.tosUrl?.let { makeAbsoluteIfRelativeUrl(baseUrl, it) },
plans =
serverInfo.plans.mapValues { u ->
u.value.copy(
url = u.value.url?.let { makeAbsoluteIfRelativeUrl(baseUrl, it) },
)
},
)
}
suspend fun loadInfo(baseUrl: String): ServerInfo {
suspend fun loadInfo(
baseUrl: String,
forceProxy: Boolean,
): ServerInfo {
checkNotInMainThread()
val request: Request =
@@ -92,13 +116,13 @@ 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()
try {
if (it.isSuccessful) {
return parse(body)
return parse(baseUrl, body)
} else {
throw RuntimeException(
"Resulting Message from $baseUrl is an error: ${response.code} ${response.message}",
@@ -117,3 +141,18 @@ class Nip96Retriever {
typealias PlanName = String
typealias MimeType = String
fun makeAbsoluteIfRelativeUrl(
baseUrl: String,
potentialyRelativeUrl: String,
): String =
try {
val apiUrl = URI(potentialyRelativeUrl)
if (apiUrl.isAbsolute) {
potentialyRelativeUrl
} else {
URL(URL(baseUrl), potentialyRelativeUrl).toString()
}
} catch (e: Exception) {
potentialyRelativeUrl
}
@@ -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 {
@@ -190,8 +196,26 @@ class Nip96Uploader(
}
}
} else {
val msg = response.body.string()
val errorMessage =
try {
val tree = jacksonObjectMapper().readTree(msg)
val status = tree.get("status")?.asText()
val message = tree.get("message")?.asText()
if (status == "error" && message != null) {
message
} else {
null
}
} catch (e: Exception) {
null
}
val explanation = HttpStatusMessages.resourceIdFor(response.code)
if (explanation != null) {
if (errorMessage != null) {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, errorMessage))
} else if (explanation != null) {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, stringRes(context, explanation)))
} else {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, response.code))
@@ -204,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()
@@ -243,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) {
@@ -258,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()) }
@@ -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)
}
}
}
@@ -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,
@@ -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
}
@@ -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
@@ -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)
@@ -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,14 +43,20 @@ 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
class EventNotificationConsumer(
private val applicationContext: Context,
) {
companion object {
const val TAG = "EventNotificationConsumer"
}
suspend fun consume(event: GiftWrapEvent) {
Log.d("EventNotificationConsumer", "New Notification Arrived")
Log.d(TAG, "New Notification Arrived")
if (!LocalCache.justVerify(event)) return
if (!notificationManager().areNotificationsEnabled()) return
@@ -57,14 +64,15 @@ class EventNotificationConsumer(
// Test with all logged in accounts
var matchAccount = false
LocalPreferences.allSavedAccounts().forEach {
if (!matchAccount && it.hasPrivKey || it.loggedInWithExternalSigner) {
if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner)) {
LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc ->
Log.d("EventNotificationConsumer", "New Notification Testing if for ${it.npub}")
Log.d(TAG, "New Notification Testing if for ${it.npub}")
try {
consumeIfMatchesAccount(event, acc)
matchAccount = true
} catch (e: Exception) {
// Not for this account.
if (e is CancellationException) throw e
Log.d(TAG, "Message was not for user ${it.npub}: ${e.message}")
}
}
}
@@ -78,27 +86,30 @@ 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.unwrap(signer) { notificationEvent ->
pushWrappedEvent.unwrapThrowing(signer) { notificationEvent ->
val consumed = LocalCache.hasConsumed(notificationEvent)
val verified = LocalCache.justVerify(notificationEvent)
Log.d("EventNotificationConsumer", "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${signer.pubKey} consumed= $consumed && verified= $verified")
Log.d(TAG, "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${signer.pubKey} consumed= $consumed && verified= $verified")
if (!consumed && verified) {
Log.d("EventNotificationConsumer", "New Notification was verified")
Log.d(TAG, "New Notification was verified")
unwrapAndConsume(notificationEvent, signer) { innerEvent ->
Log.d("EventNotificationConsumer", "Unwrapped consume $consumed ${innerEvent.javaClass.simpleName}")
if (!consumed) {
if (innerEvent is PrivateDmEvent) {
Log.d("EventNotificationConsumer", "New Nip-04 DM to Notify")
notify(innerEvent, signer, account)
} else if (innerEvent is LnZapEvent) {
Log.d("EventNotificationConsumer", "New Zap to Notify")
notify(innerEvent, signer, account)
} else if (innerEvent is ChatMessageEvent) {
Log.d("EventNotificationConsumer", "New ChatMessage to Notify")
notify(innerEvent, signer, account)
}
Log.d(TAG, "Unwrapped consume $consumed ${innerEvent.javaClass.simpleName}")
if (innerEvent is PrivateDmEvent) {
Log.d(TAG, "New Nip-04 DM to Notify")
notify(innerEvent, signer, account)
} else if (innerEvent is LnZapEvent) {
Log.d(TAG, "New Zap to Notify")
notify(innerEvent, signer, account)
} else if (innerEvent is ChatMessageEvent) {
Log.d(TAG, "New ChatMessage to Notify")
notify(innerEvent, signer, account)
}
}
}
@@ -147,6 +158,7 @@ class EventNotificationConsumer(
// old event being re-broadcasted
event.pubKey != signer.pubKey
) { // from the user
Log.d(TAG, "Notifying")
val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return
val chatNote = LocalCache.getNoteIfExists(event.id) ?: return
val chatRoom = event.chatroomKey(signer.pubKey)
@@ -251,41 +263,41 @@ class EventNotificationConsumer(
signer: NostrSigner,
acc: AccountSettings,
) {
Log.d("EventNotificationConsumer", "Notify Start ${event.toNostrUri()}")
Log.d(TAG, "Notify Start ${event.toNostrUri()}")
val noteZapEvent = LocalCache.getNoteIfExists(event.id) ?: return
Log.d("EventNotificationConsumer", "Notify Not Notified Yet")
Log.d(TAG, "Notify Not Notified Yet")
// old event being re-broadcast
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
Log.d("EventNotificationConsumer", "Notify Not an old event")
Log.d(TAG, "Notify Not an old event")
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
val noteZapped =
event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
Log.d("EventNotificationConsumer", "Notify ZapRequest $noteZapRequest zapped $noteZapped")
Log.d(TAG, "Notify ZapRequest $noteZapRequest zapped $noteZapped")
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
Log.d("EventNotificationConsumer", "Notify Amount Bigger than 10")
Log.d(TAG, "Notify Amount Bigger than 10")
if (event.isTaggedUser(signer.pubKey)) {
val amount = showAmount(event.amount)
Log.d("EventNotificationConsumer", "Notify Amount $amount")
Log.d(TAG, "Notify Amount $amount")
(noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
decryptZapContentAuthor(noteZapRequest, signer) {
Log.d("EventNotificationConsumer", "Notify Decrypted if Private Zap ${event.id}")
Log.d(TAG, "Notify Decrypted if Private Zap ${event.id}")
val author = LocalCache.getOrCreateUser(it.pubKey)
val senderInfo = Pair(author, it.content.ifBlank { null })
if (noteZapped.event?.content() != null) {
decryptContent(noteZapped, signer) {
Log.d("EventNotificationConsumer", "Notify Decrypted if Private Note")
Log.d(TAG, "Notify Decrypted if Private Note")
val zappedContent = it.split("\n").get(0)
@@ -311,7 +323,7 @@ class EventNotificationConsumer(
val userPicture = senderInfo.first.profilePicture()
val noteUri = "nostr:Notifications"
Log.d("EventNotificationConsumer", "Notify ${event.id} $content $title $noteUri")
Log.d(TAG, "Notify ${event.id} $content $title $noteUri")
notificationManager()
.sendZapNotification(
@@ -326,7 +338,7 @@ class EventNotificationConsumer(
}
} else {
// doesn't have a base note to refer to.
Log.d("EventNotificationConsumer", "Notify Zapped note not available")
Log.d(TAG, "Notify Zapped note not available")
val user = senderInfo.first.toBestDisplayName()
var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount)
@@ -342,7 +354,7 @@ class EventNotificationConsumer(
val userPicture = senderInfo.first.profilePicture()
val noteUri = "nostr:Notifications"
Log.d("EventNotificationConsumer", "Notify ${event.id} $content $title $noteUri")
Log.d(TAG, "Notify ${event.id} $content $title $noteUri")
notificationManager()
.sendZapNotification(
@@ -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,19 +166,24 @@ 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")
} catch (e: java.lang.Exception) {
if (e is CancellationException) throw e
Log.e(tag, "Unable to register with push server", e)
}
}
suspend fun go(notificationToken: String) =
withContext(Dispatchers.IO) {
signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) }
suspend fun go(notificationToken: String) {
if (notificationToken.isNotEmpty()) {
withContext(Dispatchers.IO) {
signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) }
PushNotificationUtils.hasInit = true
PushNotificationUtils.hasInit = true
}
}
}
}
@@ -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
}
}
}
@@ -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 =
@@ -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()
@@ -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))
}
@@ -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)
@@ -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
@@ -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) {
@@ -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,
@@ -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 {
@@ -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")
}
}
@@ -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)
}
}
@@ -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)
}
}
}
}
@@ -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)
}
@@ -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)
}
}
@@ -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
@@ -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)
}
@@ -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
@@ -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
}
@@ -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))
}
}
}
},
@@ -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.note.ChannelName
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()
}
@@ -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,
@@ -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,
) {
@@ -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,
)
}
}
}
}
}
@@ -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,
@@ -1654,10 +1722,23 @@ fun ImageVideoDescription(
fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList()
}
var selectedServer by remember { mutableStateOf(ServerOption(defaultServer, false)) }
var selectedServer by remember {
mutableStateOf(
ServerOption(
fileServers
.firstOrNull { it.server == defaultServer }
?.server
?: fileServers[0].server,
false,
),
)
}
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
@@ -1778,7 +1859,7 @@ fun ImageVideoDescription(
label = stringRes(id = R.string.file_server),
placeholder =
fileServers
.firstOrNull { it.server == accountViewModel.account.settings.defaultFileServer }
.firstOrNull { it.server == defaultServer }
?.server
?.name
?: fileServers[0].server.name,
@@ -1835,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(
@@ -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
@@ -225,7 +225,7 @@ fun NewUserMetadataView(
onValueChange = { postViewModel.lnAddress.value = it },
placeholder = {
Text(
text = "me@mylightiningnode.com",
text = "me@mylightningnode.com",
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -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,
)
}
}
@@ -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 = {
@@ -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
@@ -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))
}
}
@@ -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),
@@ -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()
@@ -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()
@@ -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()
@@ -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)
}
}
}
@@ -20,7 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -31,6 +32,8 @@ 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 com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -41,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChatMaxWidth
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BasicRelaySetupInfoClickableRow(
item: BasicRelaySetupInfo,
@@ -50,7 +54,17 @@ fun BasicRelaySetupInfoClickableRow(
onClick: () -> Unit,
accountViewModel: AccountViewModel,
) {
Column(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
val clipboardManager = LocalClipboardManager.current
Column(
Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
onLongClick = {
clipboardManager.setText(AnnotatedString(item.briefInfo.url))
},
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = HalfVertPadding,
@@ -65,6 +79,7 @@ fun BasicRelaySetupInfoClickableRow(
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
loadProfilePicture,
loadRobohash,
item.relayStat.pingInMs,
MaterialTheme.colorScheme.largeRelayIconModifier,
)
@@ -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
@@ -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)
},
@@ -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 {
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.actions.relays
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -59,8 +58,10 @@ 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.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -70,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
@@ -101,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)
}
}
}
@@ -115,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 ->
@@ -131,10 +135,8 @@ fun LazyListScope.renderKind3Items(
onToggleSearch = { postViewModel.toggleSearch(it) },
onDelete = { postViewModel.deleteRelay(it) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
nav = nav,
)
}
item {
@@ -147,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(
@@ -157,10 +158,7 @@ fun LazyListScope.renderKind3ProposalItems(
postViewModel.addRelay(item)
},
accountViewModel = accountViewModel,
nav = {
onClose()
nav(it)
},
nav = nav,
)
HorizontalDivider(
thickness = DividerThickness,
@@ -213,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
@@ -292,6 +290,7 @@ fun LoadRelayInfo(
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ClickableRelayItem(
item: Kind3BasicRelaySetupInfo,
@@ -307,7 +306,17 @@ fun ClickableRelayItem(
onDelete: (Kind3BasicRelaySetupInfo) -> Unit,
onClick: () -> Unit,
) {
Column(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
val clipboardManager = LocalClipboardManager.current
Column(
Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
onLongClick = {
clipboardManager.setText(AnnotatedString(item.briefInfo.url))
},
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 5.dp),
@@ -322,6 +331,7 @@ fun ClickableRelayItem(
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
loadProfilePicture,
loadRobohash,
item.relayStat.pingInMs,
MaterialTheme.colorScheme.largeRelayIconModifier,
)
@@ -676,6 +686,7 @@ private fun ActiveToggles(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FirstLine(
item: Kind3BasicRelaySetupInfo,
@@ -683,11 +694,18 @@ private fun FirstLine(
onDelete: (Kind3BasicRelaySetupInfo) -> Unit,
modifier: Modifier,
) {
val clipboardManager = LocalClipboardManager.current
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Text(
text = item.briefInfo.displayUrl,
modifier = Modifier.clickable(onClick = onClick),
modifier =
Modifier.combinedClickable(
onClick = onClick,
onLongClick = {
clipboardManager.setText(AnnotatedString(item.briefInfo.url))
},
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -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)
},
@@ -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
@@ -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
@@ -93,6 +95,7 @@ fun Kind3RelaySetupInfoProposalRow(
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
loadProfilePicture,
loadRobohash,
item.relayStat.pingInMs,
MaterialTheme.colorScheme.largeRelayIconModifier,
)
@@ -137,7 +140,6 @@ fun Kind3RelaySetupInfoProposalRow(
@Preview
@Composable
fun UsedByPreview() {
val accountViewModel = mockAccountViewModel()
ThemeComparisonColumn {
UsedBy(
item =
@@ -150,9 +152,9 @@ fun UsedByPreview() {
paidRelay = false,
users = listOf("User1", "User2", "User3", "User4"),
),
accountViewModel = accountViewModel,
) {
}
accountViewModel = mockAccountViewModel(),
nav = EmptyNav,
)
}
}
@@ -161,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 {
@@ -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 {
@@ -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 {
@@ -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)
},
@@ -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 {
@@ -55,11 +55,13 @@ 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.note.LoadUser
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
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
@@ -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
@@ -135,6 +139,7 @@ fun RelayInformationDialog(
iconUrl = relayInfo.icon ?: relayBriefInfo.favIcon,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
RelayStats.get(url = relayBriefInfo.url).pingInMs,
iconModifier = MaterialTheme.colorScheme.largeRelayIconModifier,
)
}
@@ -151,10 +156,7 @@ fun RelayInformationDialog(
Section(stringRes(R.string.owner))
relayInfo.pubkey?.let {
DisplayOwnerInformation(it, accountViewModel) {
onClose()
nav(it)
}
DisplayOwnerInformation(it, accountViewModel, newNav)
}
}
item {
@@ -299,7 +301,7 @@ fun RelayInformationDialog(
backgroundColor = color,
id = msg.hashCode().toString(),
accountViewModel = accountViewModel,
nav = nav,
nav = newNav,
)
}
}
@@ -352,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) {
@@ -20,7 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -34,6 +35,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
@@ -41,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.WarningColor
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RelayNameAndRemoveButton(
item: BasicRelaySetupInfo,
@@ -48,11 +52,18 @@ fun RelayNameAndRemoveButton(
onDelete: (BasicRelaySetupInfo) -> Unit,
modifier: Modifier,
) {
val clipboardManager = LocalClipboardManager.current
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Text(
text = item.briefInfo.displayUrl,
modifier = Modifier.clickable(onClick = onClick),
modifier =
Modifier.combinedClickable(
onClick = onClick,
onLongClick = {
clipboardManager.setText(AnnotatedString(item.briefInfo.url))
},
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -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 {
@@ -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,
)
}
}
}
@@ -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,
)
}
}
@@ -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,
),
@@ -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),
)
}
@@ -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)
}
},
)
@@ -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),
)
}
@@ -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()
}
}
}
}
@@ -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)
}
}
}
@@ -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 {
@@ -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
}
}
@@ -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,
@@ -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,
}
@@ -85,10 +85,12 @@ 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.note.LoadUser
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
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LoadUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder
@@ -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)

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