Compare commits

...

916 Commits

Author SHA1 Message Date
Vitor Pamplona
f94feebbd7 v0.94.2 2025-01-04 17:47:13 -05:00
Vitor Pamplona
82d006f65e letter change to avoid mixing with other log messages 2025-01-04 17:44:43 -05:00
Vitor Pamplona
3a553fa550 Updates JNA and URI Normalizer 2025-01-04 17:41:47 -05:00
Vitor Pamplona
1e9c3a47a4 Merge pull request #1249 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-01-04 17:40:46 -05:00
Crowdin Bot
3d9b4431ad New Crowdin translations by GitHub Action 2025-01-04 22:37:25 +00:00
Vitor Pamplona
6522d74253 Fixes the Tor leaks for relays and some image content. 2025-01-04 17:35:34 -05:00
Vitor Pamplona
8a8ae4fb8b Merge pull request #1248 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-01-03 20:39:08 -05:00
Crowdin Bot
6cac5fa5e4 New Crowdin translations by GitHub Action 2025-01-04 01:38:36 +00:00
Vitor Pamplona
aa3063acae v0.94.1 2025-01-03 20:36:26 -05:00
Vitor Pamplona
46de90ea65 Removing the unecessary loading of the emoji suggestions when creating the new post view model 2025-01-03 20:36:18 -05:00
Vitor Pamplona
aa44560714 Adds support for Custom Reaction selection on new posts and direct GIF url usage. 2025-01-03 20:29:31 -05:00
Vitor Pamplona
c9a6b63f67 v0.94.0 2025-01-03 11:02:15 -05:00
David Kaspar
47211e9136 Merge pull request #1247 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-01-03 15:29:44 +00:00
Crowdin Bot
cfbcc49940 New Crowdin translations by GitHub Action 2025-01-03 15:28:37 +00:00
Vitor Pamplona
b4a497fd11 Merge pull request #1246 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-01-03 10:27:22 -05:00
Crowdin Bot
9aa287f96a New Crowdin translations by GitHub Action 2025-01-03 15:16:06 +00:00
David Kaspar
a6880f339c Merge pull request #1245 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-01-03 15:14:36 +00:00
Crowdin Bot
43996499ba New Crowdin translations by GitHub Action 2025-01-03 15:13:40 +00:00
Vitor Pamplona
5c0c1acc4a Allowing multiple images on a single event to be displayed in the Profile gallery 2025-01-03 10:11:10 -05:00
Vitor Pamplona
4bfacbc11e Improves the spacing in the layout of the gallery 2025-01-02 19:42:17 -05:00
Vitor Pamplona
633c9e8dcf Merge pull request #1244 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-01-02 19:04:53 -05:00
Crowdin Bot
0f2a4d104c New Crowdin translations by GitHub Action 2025-01-02 23:55:24 +00:00
Vitor Pamplona
fb92d2da2f Merge pull request #1243 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-01-02 18:54:30 -05:00
Vitor Pamplona
d29e90fe8f Adjusts the top bar when uploading media to DMs to match the chat top bar 2025-01-02 18:53:44 -05:00
Crowdin Bot
27329b7a0f New Crowdin translations by GitHub Action 2025-01-02 23:11:04 +00:00
Vitor Pamplona
577ef6437f removes logs 2025-01-02 18:09:27 -05:00
Vitor Pamplona
dbe593db98 Adds a server and compression selection screen when uploading images on DMs 2025-01-02 18:06:41 -05:00
Vitor Pamplona
ff9509b649 Refactors user suggestion views 2025-01-02 17:58:06 -05:00
Vitor Pamplona
2772bdff8b Increases the number of active playbacks on the screen 2025-01-02 10:52:17 -05:00
Vitor Pamplona
c6869eaff6 Merge pull request #1242 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-01-02 09:04:49 -05:00
Crowdin Bot
663f5f282e New Crowdin translations by GitHub Action 2025-01-02 13:57:39 +00:00
Vitor Pamplona
9f147f03c7 Fixes https://github.com/vitorpamplona/amethyst/issues/1241 2025-01-02 08:55:42 -05:00
Vitor Pamplona
0ceccb23c6 Avoids cutting off some of the payables in the Pay intent screen 2024-12-31 16:21:35 -05:00
Vitor Pamplona
ee65169896 Final touches of the new error message dashboard 2024-12-31 16:21:14 -05:00
Vitor Pamplona
6ac930c241 Don't show the "message the user" button when the problem is local 2024-12-31 16:20:53 -05:00
Vitor Pamplona
a09b8c5862 Moves to using cancellable coroutines to avoid cancelling the parent group all together. 2024-12-31 16:20:10 -05:00
Vitor Pamplona
160d4722c0 Refactoring Zap Error message screen to allow sending messages directly to each receiver with an error 2024-12-31 15:51:31 -05:00
Vitor Pamplona
497ae937fd Fixes the navigation padding on Samsung devices for the Shorts tab. 2024-12-30 20:32:53 -05:00
Vitor Pamplona
b366aee1a9 Makes sure to keep the chat's input on top of the system nav 2024-12-30 20:13:38 -05:00
Vitor Pamplona
09539ac22a adds new rules for proguard 2024-12-30 19:57:31 -05:00
Vitor Pamplona
e1e3a0e6a0 Expands the Around me filter to 50km 2024-12-30 19:57:20 -05:00
Vitor Pamplona
54e6be8ef3 Fixes notes appearing of replies for some recipes 2024-12-30 16:03:00 -05:00
Vitor Pamplona
e8462fe67f Fully removing the swipe to delete from right to left. 2024-12-30 15:56:24 -05:00
Vitor Pamplona
626daa9967 Refactoring 2024-12-30 15:43:09 -05:00
Vitor Pamplona
266d4cb34f Solves crashing when a p-tag contains only "ffff" 2024-12-30 15:42:54 -05:00
Vitor Pamplona
fc72e20da7 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-12-30 08:51:24 -05:00
Vitor Pamplona
594abd1d7a Updates zoomable 2024-12-30 08:51:14 -05:00
Vitor Pamplona
a0d8a770b7 Merge pull request #1239 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-12-30 08:50:31 -05:00
Crowdin Bot
fc7b44b9d0 New Crowdin translations by GitHub Action 2024-12-30 13:39:56 +00:00
Vitor Pamplona
00eb4e364a Merge pull request #1238 from davotoula/display-toast-on-media-download-start
Display toast on media download start
2024-12-30 08:38:31 -05:00
David Kaspar
e88a36187d added translations 2024-12-30 14:16:30 +01:00
David Kaspar
21d80518c9 strings refactor 2024-12-30 14:07:06 +01:00
David Kaspar
7999608f28 added translatable strings 2024-12-30 14:03:28 +01:00
David Kaspar
ee20508e4b added video download started toast 2024-12-30 13:56:39 +01:00
David Kaspar
1231a1483f Corrected "added to profile gallery" toast text 2024-12-30 13:45:02 +01:00
David Kaspar
f8dd82f592 added a first toast for start of image download 2024-12-30 13:21:11 +01:00
Vitor Pamplona
ca00e6a302 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-12-28 16:43:34 -05:00
Vitor Pamplona
aeba5d1b51 delete unnecessary comment 2024-12-28 16:43:05 -05:00
Vitor Pamplona
3e014e7b0f Merge pull request #1237 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-12-28 15:32:06 -05:00
Crowdin Bot
95cda8ec08 New Crowdin translations by GitHub Action 2024-12-28 20:20:54 +00:00
Vitor Pamplona
1f3a818922 Remove crossfades and double composition on image success 2024-12-28 15:15:52 -05:00
Vitor Pamplona
7fe02fcf2c Disables delete draft from swipe from right to left. Only left to right remains available. 2024-12-27 18:25:24 -05:00
Vitor Pamplona
1c52887e3a Merge pull request #1236 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-12-27 17:02:07 -05:00
Crowdin Bot
62d95e2128 New Crowdin translations by GitHub Action 2024-12-27 21:52:27 +00:00
Vitor Pamplona
d0961d08da Fixes edge to edge issues when the keyboard is shown. 2024-12-27 16:49:43 -05:00
Vitor Pamplona
f38383d4e4 Removes debugging logs 2024-12-25 11:09:20 -05:00
Vitor Pamplona
739a073d4e Updates Vico, mockk, kotlin and adaptive acompanist 2024-12-24 18:22:53 -05:00
Vitor Pamplona
a195167a56 Fixes keyboard overriding the relay settings screen 2024-12-24 17:55:26 -05:00
Vitor Pamplona
72c089858d Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-12-24 16:05:43 -05:00
Vitor Pamplona
92fcf05557 Fixes double quotes on NIP-28 Channel messages 2024-12-24 15:53:45 -05:00
Vitor Pamplona
73d38f6257 Merge pull request #1233 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-12-24 11:31:53 -05:00
Crowdin Bot
48d8e418e8 New Crowdin translations by GitHub Action 2024-12-24 16:30:04 +00:00
Vitor Pamplona
ff427d0fde Fixes image upload testing. 2024-12-24 11:27:44 -05:00
Vitor Pamplona
6807554d1d Merge pull request #1232 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-12-23 20:40:49 -05:00
Crowdin Bot
433a15cf0a New Crowdin translations by GitHub Action 2024-12-24 01:33:04 +00:00
Vitor Pamplona
94c74a1e0c Adds support for encrypted media uploads on NIP-17 DMs 2024-12-23 20:30:22 -05:00
Vitor Pamplona
f839565152 Refactors Ammolite to remove the dependency on OkHttp to prepare for KTor and multiplatform settings.
- This also reduces the Singleton coupling between Client and RelayPool.

To migrate, create a NostrClient instance on your Application class and update your code to access that `client` instance.
2024-12-20 15:36:50 -05:00
Vitor Pamplona
d9c14a78a7 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-12-18 20:55:55 -05:00
Vitor Pamplona
a9d87883d2 Integrates with Pokey's Broadcast receiver. 2024-12-18 20:55:45 -05:00
Vitor Pamplona
66e7559ddb Merge pull request #1231 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-12-18 20:32:34 -05:00
Crowdin Bot
2cd9a7dca6 New Crowdin translations by GitHub Action 2024-12-19 01:10:26 +00:00
Vitor Pamplona
123239443d organize imports 2024-12-18 20:08:27 -05:00
Vitor Pamplona
76ce8f2974 Fixes bug that resets localhost relay settings. 2024-12-18 18:38:26 -05:00
Vitor Pamplona
e002e5c10f Merge pull request #1229 from davotoula/account-backup-qr-code
Account backup qr codes
2024-12-18 18:28:13 -05:00
Vitor Pamplona
d101397166 Merge pull request #1230 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-12-18 17:58:02 -05:00
Crowdin Bot
2fd96da7c7 New Crowdin translations by GitHub Action 2024-12-18 22:48:35 +00:00
Vitor Pamplona
06bc1922cb Fixes tests 2024-12-18 17:27:20 -05:00
David Kaspar
9f1b5a3c1a added content description to QR code icons 2024-12-18 23:23:57 +01:00
David Kaspar
4f4da12aa7 refactor QrCodeButton methods to avoid duplications 2024-12-18 23:06:37 +01:00
Vitor Pamplona
17495631f7 Simplifies a bit of the game controller icon 2024-12-18 17:01:29 -05:00
Vitor Pamplona
0041d87645 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-12-18 16:53:11 -05:00
Vitor Pamplona
579f63935a - Adds support for multiple media uploads at the same time.
- Adds support to display PictureEvents with multiple images at the same time
- Improves Uploading feedback for the NewPost screen
- 10x better performance on Blurhash generation
- Fixes cosine caching on Blurhash
- Removes troublesome dependency on blurhash encoder liberary
- Restructures contentScale for Images and Video dialogs
- Refactors Media Uploaders to improve code reuse
- Refactors iMeta usage on Quartz to move away from NIP-94
2024-12-18 16:52:50 -05:00
David Kaspar
b273e6dd4f rearrange code for easier diff 2024-12-18 22:52:01 +01:00
David Kaspar
eec8212070 added keyguard to qr code encrypted key export 2024-12-18 20:54:14 +01:00
David Kaspar
af5f136e6c added keyguard to qr code key export 2024-12-18 20:18:23 +01:00
David Kaspar
12d646e8a9 Move QR code next to button 2024-12-18 15:08:06 +01:00
David Kaspar
bd26fcfafd Clean up layout 2024-12-18 14:54:09 +01:00
David Kaspar
ca05b768df Added QR code private key export dialog 2024-12-18 14:43:23 +01:00
Vitor Pamplona
b75024e1fb Merge pull request #1227 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-12-16 10:10:44 -05:00
Crowdin Bot
816c598971 New Crowdin translations by GitHub Action 2024-12-16 15:08:47 +00:00
Vitor Pamplona
5636748cf8 Merge pull request #1223 from davotoula/add-custom-hashtag-icon-gamestr
Added Gamestr custom hashtag icon
2024-12-16 10:07:25 -05:00
Vitor Pamplona
3defc4d45b Merge pull request #1226 from greenart7c3/main
Fix download and loading icon not showing when text overflow
2024-12-16 10:06:17 -05:00
greenart7c3
ee16324673 Fix download and loading icon not showing when text overflow 2024-12-16 10:40:18 -03:00
David Kaspar
dfabd7afdd Added Gamestr custom hashtag icon 2024-12-15 21:52:30 +01:00
Vitor Pamplona
728768632c Update Compose Bom, firebase, navigation compose and android camera libs 2024-12-12 14:16:58 -05:00
Vitor Pamplona
c2243c53cf Optimizes user search to account for names that start with the typed prefix 2024-12-10 19:07:37 -05:00
Vitor Pamplona
5aed1cee2c Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-12-10 18:46:08 -05:00
Vitor Pamplona
8809593bbc - adds an account switcher to the notification click
- removes the use of nostr: for notifications
- enables a new screen to be routed when new logins happen
2024-12-10 18:45:59 -05:00
Vitor Pamplona
83e942a7be Fixes lack of notification when a new account is logged into the app and before the app restarts. 2024-12-10 18:39:45 -05:00
Vitor Pamplona
1ee6316e92 Merge pull request #1221 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-12-10 15:28:07 -05:00
Crowdin Bot
357ca9b6eb New Crowdin translations by GitHub Action 2024-12-10 20:16:01 +00:00
Vitor Pamplona
b0f5e77077 Merge pull request #1220 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-12-10 15:14:39 -05:00
Crowdin Bot
1b7b8fa268 New Crowdin translations by GitHub Action 2024-12-10 19:22:17 +00:00
Vitor Pamplona
82d8411ac6 Merge pull request #1219 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-12-10 14:21:00 -05:00
Crowdin Bot
684b78d2df New Crowdin translations by GitHub Action 2024-12-10 19:19:46 +00:00
Vitor Pamplona
8b80ac13db Fixes displaying an old result when coming back to a DVM screen 2024-12-10 14:17:50 -05:00
Vitor Pamplona
83235dab69 Removes contract of the old image picker 2024-12-10 14:17:15 -05:00
Vitor Pamplona
b01f46b2ee Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-12-09 14:59:44 -05:00
Vitor Pamplona
6a787f2774 Moves relayToAdd from the Object to a param in the relay screen 2024-12-09 14:59:35 -05:00
Vitor Pamplona
53206cc0b2 Merge pull request #1218 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-12-09 14:43:09 -05:00
Crowdin Bot
7cb6854489 New Crowdin translations by GitHub Action 2024-12-09 19:32:52 +00:00
Vitor Pamplona
95966e5874 Merge pull request #1214 from greenart7c3/main
Move relay dialog to a route
2024-12-09 14:31:24 -05:00
Vitor Pamplona
723979f185 Merge pull request #1216 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-12-07 18:10:42 -05:00
Crowdin Bot
b989bc0236 New Crowdin translations by GitHub Action 2024-12-07 23:01:56 +00:00
Vitor Pamplona
54ab8f1448 Merge pull request #1215 from davotoula/improve-accessibility-add-content-descriptions
Improve accessibility add content descriptions
2024-12-07 18:00:18 -05:00
David Kaspar
1b0ff52c61 translations 2024-12-07 21:29:34 +01:00
David Kaspar
46b70b719a re-add key to LaunchedEffect 2024-12-07 21:21:47 +01:00
David Kaspar
1972cdeafb Add content description to custom emojis 2024-12-07 21:06:21 +01:00
David Kaspar
39a7d98651 Add content description to icon in app definition 2024-12-07 16:56:24 +01:00
greenart7c3
a93910f8cb Move relay dialog to a route 2024-12-07 11:17:38 -03:00
David Kaspar
314cbe1a32 Add content description to recommended apps icons - refactored 2024-12-07 15:14:46 +01:00
David Kaspar
54d0ec806d Add content description to recommended apps icons 2024-12-07 15:12:27 +01:00
David Kaspar
6faab55a55 Add content description to pushpin icon (just in case) 2024-12-07 14:38:58 +01:00
David Kaspar
8b5366f9e2 Add description to relay list selector in NewMediaView 2024-12-07 14:11:09 +01:00
Vitor Pamplona
15b527c58d Fixes blurhash rendering 2024-12-06 18:13:40 -05:00
Vitor Pamplona
e501101062 Merge pull request #1213 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-12-06 15:57:41 -05:00
Crowdin Bot
15411edfa6 New Crowdin translations by GitHub Action 2024-12-06 20:07:57 +00:00
Vitor Pamplona
e65346d268 Move gallery previews to it's own package. Avoid reusing VideoView 2024-12-06 15:05:59 -05:00
Vitor Pamplona
01b880c4a5 Simplifies the Gallery stack 2024-12-06 13:41:03 -05:00
Vitor Pamplona
0d6760360f Fixes the bugs from migrating video events to imeta tags 2024-12-06 10:19:55 -05:00
Vitor Pamplona
4610519bb3 - Removes pull to refresh from gallery 2024-12-06 10:17:37 -05:00
Vitor Pamplona
905655461d Adds kind picture and video events to the user profile gallery subscription. 2024-12-06 10:16:54 -05:00
Vitor Pamplona
f759acf66e Merge pull request #1210 from believethehype/olas-profile-with-legacy
Extend NIP93 Profile Gallery with Nip68 Events
2024-12-06 08:22:22 -05:00
Vitor Pamplona
8979706a81 Merge pull request #1212 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-12-06 08:21:02 -05:00
Crowdin Bot
0db664fabc New Crowdin translations by GitHub Action 2024-12-06 13:19:06 +00:00
Vitor Pamplona
c33fee59a2 Delete unused constant 2024-12-05 18:03:40 -05:00
Vitor Pamplona
eb36489094 Adds basic support for RelationshipStatus to Quartz 2024-12-05 17:55:59 -05:00
Vitor Pamplona
51ce509928 Simple refactoring to newer versions of the clickable text 2024-12-05 17:55:36 -05:00
Vitor Pamplona
0dd306b7fa Separates event class that manages general lists and abstracts another intermediary class that manages private tag arrays in its content. 2024-12-05 17:55:10 -05:00
Vitor Pamplona
8a6f830e29 Removes invalid import 2024-12-05 17:26:47 -05:00
Vitor Pamplona
bcb9ba26ab Improves search by npubs to use all relays. 2024-12-05 17:14:16 -05:00
Vitor Pamplona
3aa15889c1 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-12-05 17:12:50 -05:00
Vitor Pamplona
b47d9ad4d9 - Migrates Video events to imeta tags
- Removes youtu.be links from the video feed.
- Checks for video file types in uppercase as well as lowercase
2024-12-05 16:41:43 -05:00
David Kaspar
b504a2c0d4 Merge pull request #1211 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-12-05 17:33:30 +01:00
Crowdin Bot
69c9e84dc2 New Crowdin translations by GitHub Action 2024-12-05 15:54:05 +00:00
Vitor Pamplona
81591d6f76 v0.93.1 2024-12-05 10:48:52 -05:00
Believethehype
d2187995b8 Update strings.xml 2024-12-04 18:06:33 +01:00
Believethehype
a3257a365b hybrid solution nip68 / nip93
it keeps the nip93 gallery as before, but also adds all nip68 media to the gallery
2024-12-04 17:56:06 +01:00
Vitor Pamplona
9180eeb59f Merge pull request #1209 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-12-03 16:10:21 -05:00
Crowdin Bot
b24983b232 New Crowdin translations by GitHub Action 2024-12-03 20:56:03 +00:00
Vitor Pamplona
fe3246c94e Fixes video rotation screen in full size while playing 2024-12-03 15:53:17 -05:00
Vitor Pamplona
b9883093ac Improving performance of the Hex encoder. 2024-12-03 14:07:13 -05:00
Vitor Pamplona
869debaf9d Fixes Satellite's blossom upload 2024-12-02 20:05:13 -05:00
Vitor Pamplona
6a522f5c92 updates AGP 2024-12-02 18:03:47 -05:00
Vitor Pamplona
ae13725db5 Improves the Discovery block UI 2024-12-02 17:45:44 -05:00
Vitor Pamplona
95774ddca5 Merge pull request #1207 from believethehype/main
Content DVMS: show amount and personalized status in feed
2024-12-02 16:04:39 -05:00
Believethehype
56450cf6c5 Merge branch 'main' of https://github.com/believethehype/amethyst 2024-12-02 22:02:49 +01:00
Believethehype
2da1dda019 Merge remote-tracking branch 'upstream/main' 2024-12-02 22:00:24 +01:00
dbth
fb73f6153f Merge branch 'main' into main 2024-12-02 22:00:16 +01:00
Believethehype
62807336fd DVM: show amount and personalized in feed 2024-12-02 21:56:13 +01:00
Vitor Pamplona
fddb9b16ab updates jackson and secp256k1 2024-12-02 15:32:19 -05:00
Vitor Pamplona
0dcce5a987 Fixes lack of reactions on video feeds 2024-12-02 15:31:59 -05:00
Vitor Pamplona
16cf3c850a Merge pull request #1205 from believethehype/main
DVM: adapt nip89 naming, show picture then image (legacy)
2024-12-02 14:09:23 -05:00
Vitor Pamplona
2b17e9303a Merge pull request #1206 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-12-02 14:05:51 -05:00
Crowdin Bot
61d9145310 New Crowdin translations by GitHub Action 2024-12-02 18:59:15 +00:00
Vitor Pamplona
40921d4b03 fixes badge crash 2024-12-02 13:56:38 -05:00
Believethehype
e0e898d4b1 DVM: adapt nip89 naming, show picture then image (legacy) 2024-12-02 19:55:02 +01:00
Vitor Pamplona
bd2005ec6e Fixes screen mispositioning when rotating full screen video/image dialogs. 2024-11-29 16:40:07 -05:00
Vitor Pamplona
2c825d5380 Remove unecessary log 2024-11-29 16:32:04 -05:00
Vitor Pamplona
7fe522307a Fixes the bug on not having the video feed at the top when loading the app from scratch. 2024-11-29 15:12:54 -05:00
Vitor Pamplona
9660c739f3 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-11-29 14:50:39 -05:00
Vitor Pamplona
9025741c13 deletes unecessary logs 2024-11-29 14:50:28 -05:00
Vitor Pamplona
0ef851e4bb Merge pull request #1201 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-29 14:41:13 -05:00
Crowdin Bot
7204d337e8 New Crowdin translations by GitHub Action 2024-11-29 19:39:34 +00:00
Vitor Pamplona
cb538bc6a3 Fixes missing reply order when the direct reply is also included as a quote. 2024-11-29 14:31:31 -05:00
Vitor Pamplona
d0b0f441b1 Fixes image testing. 2024-11-27 15:42:31 -05:00
Vitor Pamplona
ddd7f19f38 Merge pull request #1198 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-27 12:09:28 -05:00
Crowdin Bot
e46c8f4c15 New Crowdin translations by GitHub Action 2024-11-27 17:02:57 +00:00
Vitor Pamplona
6b390dad30 Merge pull request #1197 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-27 12:01:24 -05:00
Crowdin Bot
50e067fbf0 New Crowdin translations by GitHub Action 2024-11-27 16:43:19 +00:00
Vitor Pamplona
04da076d95 Adds NIP-22 as replies to Interactive Stories. 2024-11-27 11:41:13 -05:00
David Kaspar
fd88dfed7d Merge pull request #1196 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-27 17:04:41 +01:00
Crowdin Bot
22905bc04e New Crowdin translations by GitHub Action 2024-11-27 15:40:29 +00:00
Vitor Pamplona
c02ced7855 Merge pull request #1195 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-27 10:39:14 -05:00
Crowdin Bot
ea1145c890 New Crowdin translations by GitHub Action 2024-11-27 15:36:35 +00:00
Vitor Pamplona
043c8eff57 Merge pull request #1194 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-27 10:35:14 -05:00
Vitor Pamplona
fbb036da4a v0.93.0 2024-11-27 10:33:59 -05:00
Crowdin Bot
71d30f83ea New Crowdin translations by GitHub Action 2024-11-27 15:26:37 +00:00
Vitor Pamplona
9a0c998654 Fixes "null" strings on profile fields. 2024-11-27 10:24:09 -05:00
Vitor Pamplona
88082b1ba3 Updates media and coil 2024-11-27 09:19:17 -05:00
Vitor Pamplona
ad2b2e6d02 Merge pull request #1193 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-27 09:16:25 -05:00
Crowdin Bot
5044a687fa New Crowdin translations by GitHub Action 2024-11-27 14:02:42 +00:00
Vitor Pamplona
298d4bb237 removes unnecessary metadata 2024-11-27 08:59:23 -05:00
Vitor Pamplona
9c180cb87a Merge pull request #1192 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-26 19:10:54 -05:00
Crowdin Bot
2e3f2044d5 New Crowdin translations by GitHub Action 2024-11-27 00:09:32 +00:00
Vitor Pamplona
bdf012f641 Adds support for Interactive Stories 2024-11-26 19:07:12 -05:00
Vitor Pamplona
034ee46543 Refactors additional factories 2024-11-26 09:28:42 -05:00
Vitor Pamplona
6a17f94cb1 Merge pull request #1190 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-25 14:31:56 -05:00
Crowdin Bot
233059d544 New Crowdin translations by GitHub Action 2024-11-25 19:31:18 +00:00
Vitor Pamplona
7bd9b6a1ec Moves to the new Android Image/Video Picker 2024-11-25 14:24:17 -05:00
Vitor Pamplona
04e77daaab Light chat bubbles 2024-11-22 19:17:50 -05:00
Vitor Pamplona
308048c352 Adds date separators on chats 2024-11-22 18:08:30 -05:00
Vitor Pamplona
3f1173a42a Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-11-22 17:01:57 -05:00
Vitor Pamplona
39726142ae Fixes account creation that follows itself. 2024-11-22 17:01:50 -05:00
Vitor Pamplona
025cbeed07 Merge pull request #1189 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-22 16:33:33 -05:00
Crowdin Bot
8a1550cbd9 New Crowdin translations by GitHub Action 2024-11-22 21:20:34 +00:00
Vitor Pamplona
49148ddc76 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-11-22 16:18:01 -05:00
Vitor Pamplona
d0e870e1b8 Adds Custom Empty Feed Screen for notifications 2024-11-22 16:17:55 -05:00
Vitor Pamplona
dbb2bfa512 Merge pull request #1188 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-22 12:26:35 -05:00
Crowdin Bot
d6b66c65fa New Crowdin translations by GitHub Action 2024-11-22 16:36:21 +00:00
Vitor Pamplona
9a3d365243 Fixes: https://github.com/vitorpamplona/amethyst/issues/1186 2024-11-22 11:33:51 -05:00
Vitor Pamplona
3b30d5493d Merge pull request #1184 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-21 18:58:17 -05:00
Crowdin Bot
7bfd1a9107 New Crowdin translations by GitHub Action 2024-11-21 23:53:47 +00:00
Vitor Pamplona
2c9e2de524 Adds support for Blossom media servers. 2024-11-21 18:51:43 -05:00
Vitor Pamplona
c89c5eb4b0 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-11-21 15:46:16 -05:00
Vitor Pamplona
5c5eb53395 Fixes translations of http urls 2024-11-21 15:46:07 -05:00
Vitor Pamplona
55f0092e03 Merge pull request #1185 from davotoula/enable-unfollow-in-note-dropdown
Enable unfollow in note dropdown
2024-11-21 15:32:44 -05:00
Vitor Pamplona
54d9ac5730 no need for identifier anymore 2024-11-21 15:15:03 -05:00
David Kaspar
d9a9137d89 cleaner code 2024-11-21 21:02:31 +01:00
David Kaspar
7beff8c835 Add unfollow to NoteDropDownMenu 2024-11-21 20:58:40 +01:00
Vitor Pamplona
4a80a16473 Adds support for pronouns when the user makes them available. 2024-11-21 11:19:13 -05:00
Vitor Pamplona
b3e3b70dd0 Marks which events can receive Comment replies as root scopes. 2024-11-19 18:01:56 -05:00
Vitor Pamplona
03662b9d1b Migrates Video feed new posts from NIP-94 to Video and Picture events. 2024-11-19 17:54:51 -05:00
Vitor Pamplona
d33421fce4 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-11-19 15:37:11 -05:00
Vitor Pamplona
4db6f5531c Adds support for Instagram feeds. https://github.com/nostr-protocol/nips/pull/1551 2024-11-19 15:36:30 -05:00
Vitor Pamplona
72ea4a44a2 Merge pull request #1183 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-19 09:31:20 -05:00
Crowdin Bot
022a9f6f6c New Crowdin translations by GitHub Action 2024-11-19 14:25:24 +00:00
Vitor Pamplona
248848799c Fully Deprecates note1, removing the last usages of the standard 2024-11-19 09:22:37 -05:00
Vitor Pamplona
72f317717b Remove the unused encryption field from NIP-94 2024-11-18 17:29:59 -05:00
Vitor Pamplona
4651074774 Fixes bug on searching for geohashes on addresses 2024-11-18 16:48:21 -05:00
Vitor Pamplona
fb058f93b6 Makes NIP-22 posts only visible to the main feed if inside the scope they were placed. 2024-11-18 16:47:47 -05:00
Vitor Pamplona
9e34e9d720 Fixes issue with the order of same-author events in a thread 2024-11-18 16:00:48 -05:00
Vitor Pamplona
04d6da28af Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-11-18 15:39:19 -05:00
Vitor Pamplona
192a43dc21 Updates compose, coil, firebase and navigation 2024-11-18 15:39:12 -05:00
Vitor Pamplona
61510ae7e3 Adds support for NIP-22 exclusive geo posts 2024-11-18 15:38:57 -05:00
Vitor Pamplona
488286ce20 Merge pull request #1181 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-18 08:25:31 -05:00
Crowdin Bot
fab6c65e98 New Crowdin translations by GitHub Action 2024-11-18 13:22:15 +00:00
Vitor Pamplona
accde8622c Merge pull request #1179 from davotoula/1172-hide-drafts-from-other-accounts-on-device
Hide drafts from other accounts on device
2024-11-18 08:20:55 -05:00
David Kaspar
4a590d67a6 move filtering of "not your drafts" to ThreadFeedFilter 2024-11-17 10:48:03 +01:00
David Kaspar
6c3200a9cc cleaner code, change cascading ifs to when 2024-11-16 10:34:01 +01:00
David Kaspar
f16143bec3 cleaner code, fewer warnings 2024-11-16 09:29:45 +01:00
David Kaspar
8216476916 cleaner code, fewer warnings 2024-11-16 08:48:30 +01:00
David Kaspar
ffd43e448b filter out drafts that are not same author as current account 2024-11-15 12:54:59 +01:00
Vitor Pamplona
3a566dad6b Improves (Show More) presence to only when it actually makes a difference. 2024-11-12 13:36:59 -05:00
Vitor Pamplona
bf861d6bc5 - Improves thread preloading
- Fixes jumping of scroll when the thread updates
2024-11-12 12:54:41 -05:00
Vitor Pamplona
b31ace79c3 App hangs when switching to an account that was logged into with the npub just because it waits to decrypt the hidden user list. 2024-11-11 18:29:38 -05:00
Vitor Pamplona
37a92c25f0 - Unifies Location Flows and GeoHash Flows into one
- Make them react to changing location permissions
- Adds UI for when the location permission is rejected.
2024-11-11 17:51:42 -05:00
Vitor Pamplona
062e4af118 Updates JNA, Coil, Jackson, and Kotlin Collections 2024-11-11 14:54:38 -05:00
Vitor Pamplona
c083295f6a Fixes error message for the signup page when the display name is blank 2024-11-11 14:53:39 -05:00
Vitor Pamplona
924baf0de3 Merge pull request #1177 from greenart7c3/main
Fix initial decryption of mute lists when using amber
2024-11-11 09:48:46 -05:00
greenart7c3
6015c968f5 Fix initial decryption of mutelists when using amber 2024-11-11 11:33:14 -03:00
Vitor Pamplona
3e85991f19 Merge pull request #1176 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-11 09:04:33 -05:00
Crowdin Bot
893b23e4fb New Crowdin translations by GitHub Action 2024-11-11 13:54:10 +00:00
Vitor Pamplona
cb5d69e358 Merge pull request #1171 from davotoula/crashfix-test-for-empty-string-in-mentions
Crashfix: test for empty string in mentions
2024-11-11 08:52:39 -05:00
Vitor Pamplona
43688c4117 Merge pull request #1174 from davotoula/1173-add-nip05-to-hidden-words-filtering
Added nip05 field from user profile to be used as part of hidden words filtering
2024-11-11 08:52:28 -05:00
David Kaspar
4e4521ecef Added nip05 field from user profile to be used as part of hidden words filtering 2024-11-10 06:51:34 +00:00
David Kaspar
7bc9205758 Added a test around NewPostViewModel.load with no mentions, valid mentions and invalid empty mentions 2024-11-08 19:15:04 +00:00
David Kaspar
abf6cc00ae test for empty string in mentions 2024-11-07 09:24:30 +00:00
Vitor Pamplona
7146838f63 Better names for github jobs 2024-11-04 11:56:25 -05:00
Vitor Pamplona
1037ab33e3 fixes names of uploaded files 2024-11-04 11:31:04 -05:00
Vitor Pamplona
230fd553ae fixes paths on github actions 2024-11-04 11:12:40 -05:00
Vitor Pamplona
eac078c42d updates build github action to test and upload the right assets 2024-11-04 10:54:14 -05:00
Vitor Pamplona
39c9b75bf4 Moves zapstore to local image and first in the list 2024-11-04 10:15:31 -05:00
Vitor Pamplona
e076a2dbe3 Merge pull request #1167 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-04 10:12:31 -05:00
Crowdin Bot
5705a26836 New Crowdin translations by GitHub Action 2024-11-04 15:12:18 +00:00
Vitor Pamplona
740c279825 Merge pull request #1165 from davotoula/1164-launch-in-scope-to-persist-translation-prefs
Crash fix: Launch in Dispatchers.IO when changing translations prefs in account
2024-11-04 10:10:48 -05:00
Vitor Pamplona
718605e44b Merge pull request #1163 from ilcompratoreconsapevole/banner
Added ZapStore's SVG banner that says "Get it on" linking to its repo
2024-11-04 10:09:49 -05:00
Vitor Pamplona
8df2ff839c Merge pull request #1162 from davotoula/bugfix-images-reported-saved-as-video
Bugfix: images reported saved as video
2024-11-04 10:09:36 -05:00
ilcompratoreconsapevole
00be9fc595 Rewritten connection link to ZapStore's banner image to ensure reliable connection to specific repo 2024-11-03 21:38:01 +01:00
David Kaspar
7d25ab13a9 Launch in Dispatchers.IO when changing translations prefs in account 2024-11-03 16:15:54 +00:00
ilcompratoreconsapevole
d7888096e9 Added ZapStore's SVG banner that says "Get it on" linking to its repository. 2024-11-03 12:50:46 +01:00
David Kaspar
aad5aaa930 rename inner it to avoid shadowed it 2024-11-03 11:48:12 +00:00
David Kaspar
b877e4105a Replace nested ifs with a when 2024-11-03 10:30:07 +00:00
David Kaspar
d1cd15c389 Remove unused val 2024-11-03 10:24:20 +00:00
David Kaspar
69e139e382 Remove unused @OptIn(ExperimentalFoundationApi::class) 2024-11-03 10:23:32 +00:00
David Kaspar
b2628764c9 Remove unused shadowed orientation val 2024-11-03 10:20:30 +00:00
David Kaspar
f7f0d74be1 Correct boolean operation from AND to OR 2024-11-03 10:19:29 +00:00
Vitor Pamplona
b47122d79a Updates accompanist, kotlin serialization and mockk 2024-11-01 18:41:00 -04:00
Vitor Pamplona
635ad30de9 Let it throw in the giftwrap parser to make sure Notifications work 2024-11-01 18:40:34 -04:00
Vitor Pamplona
0884318a55 Merge pull request #1161 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-01 18:27:38 -04:00
Crowdin Bot
d8c89317d2 New Crowdin translations by GitHub Action 2024-11-01 22:20:09 +00:00
Vitor Pamplona
772987dffd coroutines test to 1.9 2024-11-01 18:17:32 -04:00
Vitor Pamplona
61c51ee9a8 Updates AGP 2024-11-01 18:17:07 -04:00
Vitor Pamplona
f157f8eb9b - Fixes lack of zap updates when there is a private zap the user cannot decrypt or when a nwc payment fails.
- Moves the parallel processing to a utils class
2024-11-01 18:16:57 -04:00
Vitor Pamplona
d96f5953d5 Moving animation size to a constant. 2024-11-01 17:27:19 -04:00
Vitor Pamplona
ffb5cce509 Merge pull request #1160 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-01 10:35:40 -04:00
Crowdin Bot
b441292413 New Crowdin translations by GitHub Action 2024-11-01 14:16:09 +00:00
Vitor Pamplona
91748d5c39 - Adds a cache for reply levels when viewing threads.
- Moves the lazy list state to the view model to saving the scrolling position when navigating between multiple threads.
2024-11-01 10:10:05 -04:00
Vitor Pamplona
a825f575de Fixes rendering of notes when the modifier changes due to new posts found and the reply level gets recalculated. 2024-11-01 10:06:09 -04:00
Vitor Pamplona
0581a3e836 Fixes reply level calculation caching. 2024-11-01 10:05:25 -04:00
Vitor Pamplona
82f0ed8a0d Upgrades to Coil 3 2024-10-31 16:34:22 -04:00
Vitor Pamplona
116bc108c3 Merge pull request #1158 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-31 14:07:13 -04:00
Crowdin Bot
44868ac4e6 New Crowdin translations by GitHub Action 2024-10-31 18:05:03 +00:00
Vitor Pamplona
2a24ca0787 Adds support for q tags on addresses 2024-10-31 14:02:39 -04:00
Vitor Pamplona
03225fc696 Merge pull request #1157 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-30 18:00:57 -04:00
Crowdin Bot
a0ab4a824b New Crowdin translations by GitHub Action 2024-10-30 21:59:32 +00:00
Vitor Pamplona
0ba4ca13bf Updates compose, corektx, fragment, kotlin, runtime, and android camera libs 2024-10-30 17:56:28 -04:00
Vitor Pamplona
060a242a0f Removes logs 2024-10-30 17:55:35 -04:00
Vitor Pamplona
ae530ed813 adds extra padding for the zap setup screen 2024-10-30 16:02:00 -04:00
Vitor Pamplona
a5c4a53afe - Adds an Around ME feed
- Refactors location to operate as a flow
- Refactors FeedStructures to prepare for custom feeds
- Moves Account to operate feeds with location
2024-10-30 16:01:39 -04:00
Vitor Pamplona
fb6137f99a Missing string 2024-10-29 12:31:07 -04:00
Vitor Pamplona
de99c50639 Adds search.nos.today to bootstrapping relays for search 2024-10-29 12:23:09 -04:00
Vitor Pamplona
d5f8994128 Merge pull request #1156 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-29 12:16:33 -04:00
Crowdin Bot
cf02d808d6 New Crowdin translations by GitHub Action 2024-10-29 16:10:36 +00:00
Vitor Pamplona
f6aa732558 Adds DM and Search default buttons to the dialogs. 2024-10-29 12:07:15 -04:00
Vitor Pamplona
d321df79e8 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-10-28 16:11:09 -04:00
Vitor Pamplona
d1b4f81083 Fixes jumpy thread view 2024-10-28 16:10:47 -04:00
Vitor Pamplona
0205f1fd12 Merge pull request #1155 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-28 10:14:42 -04:00
Crowdin Bot
d739fdac2d New Crowdin translations by GitHub Action 2024-10-28 14:11:31 +00:00
Vitor Pamplona
4a43a87a79 Moves templates to explain that bounties matter. 2024-10-28 10:08:53 -04:00
Vitor Pamplona
6f31898cdc Updates Firebase 2024-10-24 17:34:13 -04:00
Vitor Pamplona
6ebc878312 Fixes poll's background rendering with the right percentages. 2024-10-24 17:34:00 -04:00
Vitor Pamplona
518e1c88ce Adds hidden words filter to search, hashtag and geotag feeds
Applies hidden words even to hashtags that were not included in the content of the event.
Pre-process search to avoid showing and hiding posts after hidden words where processed by the UI.
2024-10-24 11:00:07 -04:00
Vitor Pamplona
f88985b2bf Adds an icon to the repo 2024-10-23 18:00:23 -04:00
Vitor Pamplona
89e1e20b40 Improves algorithm for placing the clicked note in a visible position when opening a new threads that need to load dynamically 2024-10-22 17:26:53 -04:00
Vitor Pamplona
c2e725ef5f Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-10-22 17:14:36 -04:00
Vitor Pamplona
6e8c348cbc Moves NIP-44 test model classes to a new file 2024-10-22 17:14:29 -04:00
Vitor Pamplona
84316d320d Merge pull request #1150 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-22 15:26:36 -04:00
Crowdin Bot
87fb63af06 New Crowdin translations by GitHub Action 2024-10-22 19:00:17 +00:00
Vitor Pamplona
a3b0436d91 Uses NIP-78 to start syncing some data between instances of Amethyst 2024-10-22 14:52:11 -04:00
Vitor Pamplona
bb430605bc Merge pull request #1148 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-19 18:50:37 -04:00
Crowdin Bot
4c0d92f89f New Crowdin translations by GitHub Action 2024-10-19 18:19:59 +00:00
Vitor Pamplona
f0b14edef6 v0.92.7 2024-10-19 14:17:42 -04:00
Vitor Pamplona
aae2ffebd7 Fixes the bug of unable to decrypt mute lists. 2024-10-19 14:17:17 -04:00
Vitor Pamplona
7b11d0ce81 v0.92.6 2024-10-17 17:21:20 -04:00
Vitor Pamplona
26ba75c7c9 Fixes array too big because strfry blocks more than 20 filters 2024-10-17 17:18:35 -04:00
Vitor Pamplona
67fb3bf488 Fixes margin of poll options with translation 2024-10-17 15:22:34 -04:00
Vitor Pamplona
24b29396cb Reduces the font size for the translation label 2024-10-17 15:22:19 -04:00
Vitor Pamplona
25c3532637 Fixes poll rendering 2024-10-17 14:55:07 -04:00
Vitor Pamplona
cff6ee457d Fixes zapraiser progress bar 2024-10-17 14:18:40 -04:00
Vitor Pamplona
c0ba6f5b00 If the Share target intent opens with the new post screen already active, just updates the post instead of creating a new one. 2024-10-17 13:59:52 -04:00
Vitor Pamplona
44d8b8ff33 v0.92.5 2024-10-16 17:34:10 -04:00
Vitor Pamplona
f495acb570 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-10-16 17:33:58 -04:00
Vitor Pamplona
ecfc8bcd87 Merge pull request #1144 from geovnn/main
Prevent clicks outside boost popup
2024-10-16 17:33:39 -04:00
Vitor Pamplona
724bea6bbb Updates compose, benchmark, navigation, and activity 2024-10-16 17:32:33 -04:00
Giovanni Gatti
6826db13a4 Prevent clicks outside boost popup 2024-10-16 23:11:51 +02:00
Vitor Pamplona
c5dd2c5651 Fixes recreating the new post screen from intent when switching accounts. 2024-10-16 13:33:46 -04:00
Vitor Pamplona
236ff2d680 v0.92.4 2024-10-16 11:13:56 -04:00
Vitor Pamplona
13e2546d36 Makes Amethyst a share target for texts, images and videos. 2024-10-16 10:54:57 -04:00
Vitor Pamplona
549b9f53e5 Fixes new media post dialog for the edge to edge borders 2024-10-16 09:41:02 -04:00
Vitor Pamplona
974c022aed Fixes padding of the new new post screen 2024-10-15 17:20:16 -04:00
Vitor Pamplona
a57566dd84 Changes the new post screen to use the non-disappearing version of the scaffold. 2024-10-15 16:55:09 -04:00
Vitor Pamplona
72e86cf391 Merge pull request #1143 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-15 16:25:21 -04:00
Crowdin Bot
3b23cded56 New Crowdin translations by GitHub Action 2024-10-15 20:04:18 +00:00
Vitor Pamplona
121337eb83 Makes new post screen come in and out from the bottom 2024-10-15 16:02:20 -04:00
Vitor Pamplona
fe87d642c9 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt
2024-10-15 15:54:26 -04:00
Vitor Pamplona
b8112ff1aa Correctly maps the write status of the outbox relays. 2024-10-15 15:53:00 -04:00
Vitor Pamplona
1eee0bd1ae Merge pull request #1142 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-15 13:55:09 -04:00
Crowdin Bot
f48335e3be New Crowdin translations by GitHub Action 2024-10-15 17:46:27 +00:00
Vitor Pamplona
fcc4befb22 Merge pull request #1141 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-15 13:44:54 -04:00
Crowdin Bot
e28a1896ec New Crowdin translations by GitHub Action 2024-10-15 17:38:52 +00:00
Vitor Pamplona
f10e66e6a5 Merge pull request #1137 from geovnn/main
Change NewPostView to NewPostScreen to make it route based
2024-10-15 13:37:33 -04:00
Vitor Pamplona
825a227e2d Merge pull request #1140 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-15 12:09:51 -04:00
Crowdin Bot
3446fdaf7f New Crowdin translations by GitHub Action 2024-10-15 16:08:45 +00:00
Vitor Pamplona
9c29bf3f25 v0.92.3 2024-10-15 12:06:36 -04:00
Vitor Pamplona
9642e45775 Merge pull request #1139 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-15 12:06:23 -04:00
Crowdin Bot
58788d2cd5 New Crowdin translations by GitHub Action 2024-10-15 16:00:54 +00:00
Vitor Pamplona
d7c18341cd Uses the same Reaction animation to also do Boosts 2024-10-15 11:58:40 -04:00
Vitor Pamplona
ee16f6c143 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-10-15 11:28:41 -04:00
Vitor Pamplona
d78b55d134 Reverts hack for fling behavior on the Vertical Pager 2024-10-15 11:28:33 -04:00
Vitor Pamplona
ff5e7359e2 Merge pull request #1138 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-15 11:05:39 -04:00
Crowdin Bot
4fd0f38402 New Crowdin translations by GitHub Action 2024-10-15 14:56:33 +00:00
Vitor Pamplona
5b516f4101 Updates to AGP 8.7.1 and SDK 35 2024-10-15 10:53:21 -04:00
Vitor Pamplona
1fc390c215 Do not bring the keyboard when the search field reappears. 2024-10-15 10:52:54 -04:00
Vitor Pamplona
2bb09f583b Fixes Thread view when loading a reply of a thread that is not cached yet. 2024-10-15 10:45:03 -04:00
Giovanni Gatti
89de272ea1 Change NewPostView to NewPostScreen to make it route based 2024-10-14 23:19:43 +02:00
Vitor Pamplona
b4eba2da02 0.92.2 2024-10-11 19:35:52 -04:00
Vitor Pamplona
786a173ba5 Merge pull request #1133 from greenart7c3/external_signer_result
Check for the result field when using external signer
2024-10-11 08:08:30 -04:00
greenart7c3
5ef3c8f00e Check for the result field when using external signer 2024-10-11 07:22:34 -03:00
Vitor Pamplona
d832351bcd Fixes the ability to see muted lists in Shorts 2024-10-10 17:52:24 -04:00
Vitor Pamplona
9ae611831f Enables new fields for vision prescriptions 2024-10-10 16:48:33 -04:00
Vitor Pamplona
6db287a008 Merge pull request #1131 from davotoula/prioritise-name-when-searching
Prioritise search results with user name starting with search term
2024-10-10 09:47:28 -04:00
David Kaspar
99641e1a12 Added comparison between search term and display name to comparator to prioritise results with search term in name 2024-10-10 14:11:13 +02:00
Vitor Pamplona
abb2f0557d Merge pull request #1118 from davotoula/refactor-media-compressor
Refactor media compressor as prep for replacing 3rd party compression library
2024-10-09 16:38:29 -04:00
David Kaspar
76a66e7bd3 added test for when exception happens during compression 2024-10-09 21:46:15 +02:00
David Kaspar
6cd50a115b Merge branch 'vitorpamplona:main' into refactor-media-compressor 2024-10-09 14:16:14 +01:00
Vitor Pamplona
0ed34cec09 Fixes https://github.com/vitorpamplona/amethyst/issues/1130 2024-10-09 08:42:33 -04:00
David Kaspar
c5e3657bfe added missing dot for file extension 2024-10-09 01:45:50 +02:00
David Kaspar
da47965d76 Merge branch 'main' into refactor-media-compressor 2024-10-09 00:16:35 +01:00
David Kaspar
a66417ec82 Added Unknown media type test 2024-10-09 01:07:28 +02:00
David Kaspar
62c3c93d2e Removed splitFileName function as both file name and extension are known separately 2024-10-09 01:02:41 +02:00
David Kaspar
fbecc8b6d1 Use "use" blocks to close resources automatically 2024-10-09 00:52:35 +02:00
Vitor Pamplona
e95afa89ff Merge pull request #1129 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-08 18:38:36 -04:00
Crowdin Bot
d6f2560c1c New Crowdin translations by GitHub Action 2024-10-08 22:33:54 +00:00
Vitor Pamplona
d320b951ae Fixes long term issue with the video in the Shorts feed not aligning with the padding of the screen 2024-10-08 18:31:54 -04:00
David Kaspar
af28e6d466 Use copyTo to copy streams 2024-10-09 00:15:19 +02:00
Vitor Pamplona
86e6d56dcd Removes old logs used for debuging the previous issue 2024-10-08 17:19:35 -04:00
Vitor Pamplona
c66fa1073b Fixes intent reopening the Zap Setup screen because it was saved in the backstack of the Home screen. 2024-10-08 16:09:09 -04:00
Vitor Pamplona
6d9bffe9e7 Disabling #1122 until we solve those little issues. 2024-10-08 15:00:25 -04:00
Vitor Pamplona
04158cec6e Merge pull request #1128 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-08 14:43:07 -04:00
Crowdin Bot
76376a7c40 New Crowdin translations by GitHub Action 2024-10-08 18:32:53 +00:00
Vitor Pamplona
67ea0dcf7f Faster logout processing without closing the account switcher dialog. 2024-10-08 13:59:51 -04:00
David Kaspar
cdc8850894 Refactor out file operations into seperate helper
Additional tests for MediaCompressor
2024-10-08 17:47:27 +02:00
David Kaspar
ce0c7c842d Merge branch 'main' into refactor-media-compressor 2024-10-08 14:51:53 +01:00
Vitor Pamplona
f51f929079 Merging 2024-10-08 09:24:10 -04:00
Vitor Pamplona
d87d2e0ef8 Merge pull request #1127 from geovnn/main
Added animations
2024-10-08 09:22:40 -04:00
Giovanni Gatti
a3166f4ff3 Add animation to notification chart 2024-10-08 12:13:47 +02:00
Giovanni Gatti
9340c440f5 Add animation to FABs 2024-10-08 11:54:42 +02:00
Giovanni Gatti
a7343d5b54 Add animation to zap and reaction popups 2024-10-08 11:06:29 +02:00
Vitor Pamplona
0551b82bd9 Updates AGP and compose, fragment, navigation, benchmarking and firebase libraries. 2024-10-07 17:02:09 -04:00
Vitor Pamplona
18fca8adac Merge pull request #1122 from KotlinGeekDev/conditional-rotation
Re-enable tap to fullscreen behaviour with stricter orientation checks.
2024-10-07 12:57:28 -04:00
Vitor Pamplona
5d788ed669 Merge pull request #1125 from geovnn/main
Prevent clicks outside reaction and zap popups
2024-10-07 12:56:57 -04:00
Giovanni Gatti
54d52e0b77 Prevent clicks outside reaction and zap popups 2024-10-07 17:38:05 +02:00
Vitor Pamplona
28c92675b2 Merge pull request #1124 from greenart7c3/main
Support for login with hex key when using amber
2024-10-07 08:32:59 -04:00
greenart7c3
baa9ee0562 Support for login with hex key when using amber 2024-10-07 08:50:06 -03:00
KotlinGeekDev
7326d6be93 Rotation:actually rotate if the device orientation is not locked. 2024-10-05 17:06:36 +01:00
David Kaspar
e187e5392f refactor "from" method 2024-10-03 14:56:03 +02:00
David Kaspar
7c8f6eb239 formatting 2024-10-03 14:56:03 +02:00
David Kaspar
6294c65ad6 Create private functions for image vs video compression and when statement to select on content type 2024-10-03 14:56:03 +02:00
David Kaspar
51d9657371 Use Pair instead of Array to split up file name 2024-10-03 14:56:03 +02:00
David Kaspar
32783794ee Exit early if UNCOMPRESSED is selected 2024-10-03 14:56:03 +02:00
David Kaspar
e3858dc830 added kotlinx.coroutines.test library
added MediaCompressor first tests
2024-10-03 14:56:03 +02:00
Vitor Pamplona
38a9030963 Merge pull request #1117 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-03 08:28:43 -04:00
Crowdin Bot
29c0c4cf5f New Crowdin translations by GitHub Action 2024-10-03 12:23:07 +00:00
Vitor Pamplona
dcbde81a23 Merge pull request #1116 from davotoula/update-tests-for-new-forceProxy-parameter
Update tests for new forceProxy param
2024-10-03 08:21:47 -04:00
David Kaspar
56e6f06fe7 add "forceProxy = false" to tests 2024-10-03 10:02:31 +02:00
Vitor Pamplona
342b64f0eb Merge pull request #1114 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-30 19:18:57 -04:00
Crowdin Bot
a64a640165 New Crowdin translations by GitHub Action 2024-09-30 23:05:25 +00:00
Vitor Pamplona
6433830c32 Fixes language setting toggles 2024-09-30 18:47:45 -04:00
Vitor Pamplona
3a58819b52 Merge pull request #1112 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-28 11:54:58 -04:00
Crowdin Bot
5c7be44369 New Crowdin translations by GitHub Action 2024-09-28 15:43:33 +00:00
Vitor Pamplona
6e66d008dc Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-28 11:41:30 -04:00
Vitor Pamplona
c61c4ce16c Improves names and icons of the left drawer. 2024-09-28 11:01:56 -04:00
Vitor Pamplona
246ce55253 Merge pull request #1111 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-28 09:49:21 -04:00
Crowdin Bot
a704c81d24 New Crowdin translations by GitHub Action 2024-09-28 13:43:32 +00:00
Vitor Pamplona
9963245ec9 Improves zap store descriptions 2024-09-28 09:40:02 -04:00
Vitor Pamplona
64016aa940 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-27 18:02:33 -04:00
Vitor Pamplona
c6462f102a Merge pull request #1110 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-27 17:23:41 -04:00
Vitor Pamplona
b05083863d removes the aab files 2024-09-27 17:13:44 -04:00
Crowdin Bot
86fffe6539 New Crowdin translations by GitHub Action 2024-09-27 21:13:01 +00:00
Vitor Pamplona
f94a4cfa65 Merge pull request #1109 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-27 17:11:24 -04:00
Crowdin Bot
3825642ae7 New Crowdin translations by GitHub Action 2024-09-27 19:40:05 +00:00
Vitor Pamplona
f90f81b2a5 Merge pull request #1108 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-27 15:38:46 -04:00
Crowdin Bot
bc042e52b0 New Crowdin translations by GitHub Action 2024-09-27 19:28:52 +00:00
Vitor Pamplona
d80b22fa29 Merge pull request #1107 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-27 15:27:38 -04:00
Crowdin Bot
b63ed4fdbd New Crowdin translations by GitHub Action 2024-09-27 19:24:11 +00:00
Vitor Pamplona
397acce85b Merge pull request #1106 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-27 15:23:01 -04:00
Crowdin Bot
5305929df3 New Crowdin translations by GitHub Action 2024-09-27 19:22:04 +00:00
Vitor Pamplona
6d4ba6d507 v0.92.1 2024-09-27 15:20:06 -04:00
Vitor Pamplona
a028c2c3fc Fixes missing zaps on the first connection to a NWC wallet with a split payment. 2024-09-27 15:19:59 -04:00
Vitor Pamplona
86e5dfa799 Fixes access to Citrine 2024-09-27 14:38:14 -04:00
Vitor Pamplona
3aa316d262 v0.92.0 2024-09-27 11:14:45 -04:00
Vitor Pamplona
3f4efdb6d9 Merge pull request #1105 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-27 08:26:41 -04:00
Crowdin Bot
a178e5f589 New Crowdin translations by GitHub Action 2024-09-27 12:23:29 +00:00
Vitor Pamplona
cc5a6cdaf2 Merge pull request #1104 from davotoula/update_translations
Update de, cs, se, pt translations
2024-09-27 08:21:49 -04:00
David Kaspar
d9596335de Update de, cs, se, pt translations 2024-09-27 09:51:41 +02:00
Vitor Pamplona
44c600dfa8 Fixes icon position on chat user pictures 2024-09-26 18:36:42 -04:00
Vitor Pamplona
a92fe0bca2 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-26 17:20:36 -04:00
Vitor Pamplona
dea9d698df Adds privacy presets for Tor Settings 2024-09-26 17:20:29 -04:00
Vitor Pamplona
94ae7b3da1 Merge pull request #1103 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-26 15:53:03 -04:00
Crowdin Bot
da1c94f77b New Crowdin translations by GitHub Action 2024-09-26 19:26:05 +00:00
Vitor Pamplona
64ff33e745 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-26 15:23:47 -04:00
Vitor Pamplona
8810cfde37 Adjusts Login and Sign up screens for the new Tor Settings 2024-09-26 15:23:39 -04:00
Vitor Pamplona
c7b7ad78b7 Adds mempool to verify OTS via Tor. 2024-09-26 15:23:13 -04:00
Vitor Pamplona
ff9f25ccdb Removes unnecessary log 2024-09-26 14:42:40 -04:00
Vitor Pamplona
498595b892 Merge pull request #1102 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-26 13:48:48 -04:00
Vitor Pamplona
56878900b0 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-26 13:46:52 -04:00
Vitor Pamplona
d8d6736d55 Caches OTS web calls to avoid pinging the server repeatedly for the same event. 2024-09-26 13:45:01 -04:00
Crowdin Bot
ddc68e61d5 New Crowdin translations by GitHub Action 2024-09-26 17:43:05 +00:00
Vitor Pamplona
d4e8fc5737 Merge pull request #1101 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-26 13:41:24 -04:00
Crowdin Bot
3a7dfe7d03 New Crowdin translations by GitHub Action 2024-09-26 15:57:17 +00:00
Vitor Pamplona
d23a859f34 Improves OTS Verification error message 2024-09-26 11:55:29 -04:00
Vitor Pamplona
824cb4e5f1 @Suppress("ProduceStateDoesNotAssignValue") until we figure out what's wrong with this lint. 2024-09-26 11:36:52 -04:00
Vitor Pamplona
35aeeb571c Fixes setting the value in the main thread 2024-09-26 11:20:51 -04:00
Vitor Pamplona
70d68c89c8 Updates compose, benchmark, firebase, navigation and lifecycle dependencies. 2024-09-26 11:20:33 -04:00
Vitor Pamplona
35a6331da2 Fixes test case 2024-09-26 10:59:50 -04:00
Vitor Pamplona
6229a0ad63 making sure to only use .onion urls if the option is active 2024-09-26 10:41:36 -04:00
Vitor Pamplona
620285596d Show buffering when loading videos 2024-09-26 10:39:12 -04:00
Vitor Pamplona
fd2637ff42 Removes unused tor setting 2024-09-26 10:38:36 -04:00
Vitor Pamplona
afe8a06486 - Adds a new Settings page for Tor
- Adjusts all web calls to use Tor if that setting is enabled.
- Allows .onion urls to use Tor
- Blocks localhost from using Tor
- Moves OTS web calls to use the Tor Proxy as well.
- Starts to build all OkHttp clients from a main root node to keep the same thread pool
- Refactors the URL Preview code
- Changes ammolite to force proxy.
- Changes NIP-47 implementation to force relay for the NWC connection.
2024-09-25 17:58:37 -04:00
Vitor Pamplona
b682e90f81 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-23 21:51:23 -04:00
Vitor Pamplona
ec700ca61f Refactoring 2024-09-23 21:51:15 -04:00
Vitor Pamplona
9b26ffd92a Updates to AGP 8.6.1 2024-09-23 21:48:13 -04:00
Vitor Pamplona
a8ca9ab727 Merge pull request #1091 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-20 09:00:43 -04:00
Crowdin Bot
e5832f6a45 New Crowdin translations by GitHub Action 2024-09-20 12:57:12 +00:00
Vitor Pamplona
8b3035f635 Merge pull request #1090 from greenart7c3/main
Fix for notifications when using amber
2024-09-20 08:56:00 -04:00
greenart7c3
9f17cc50f8 Fix for notifications when using amber 2024-09-20 06:48:27 -03:00
Vitor Pamplona
d0633d07fa Fixes inability to go back after opening a nostr link from the web into Amethyst 2024-09-18 19:47:09 -04:00
Vitor Pamplona
97b88c7ceb Merge pull request #1088 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-18 17:38:59 -04:00
Crowdin Bot
4961a22753 New Crowdin translations by GitHub Action 2024-09-18 21:23:22 +00:00
Vitor Pamplona
45b568f6a7 updates amy bg 2024-09-18 17:21:44 -04:00
Vitor Pamplona
446859e8bd adds amy 2024-09-18 17:16:52 -04:00
Vitor Pamplona
e1b24c22b9 adds phone mockups 2024-09-18 17:16:38 -04:00
Vitor Pamplona
e0b1c4dc3f Sending jpg and webp version of the image 2024-09-18 16:28:07 -04:00
Vitor Pamplona
5722507b3d Adds two designed images for the homepage 2024-09-18 16:23:13 -04:00
Vitor Pamplona
74df309527 Improving bug report template 2024-09-17 18:07:41 -04:00
Vitor Pamplona
740c6a8c33 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-17 18:07:29 -04:00
Vitor Pamplona
929a263665 Adds zapstore yaml 2024-09-17 18:07:12 -04:00
Vitor Pamplona
6f44c02b5c Makes sure if the d component of an a-tag has :, it stays in the d-tag after parse. 2024-09-16 18:10:54 -04:00
Vitor Pamplona
bce01dc264 Merge pull request #1083 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-16 11:26:36 -04:00
Crowdin Bot
7919637e12 New Crowdin translations by GitHub Action 2024-09-16 15:25:34 +00:00
Vitor Pamplona
28da06ef01 Fixes #1084. Opengraph parser fails when value-less properties are present. 2024-09-16 11:23:41 -04:00
Vitor Pamplona
428ed464af Adds salt to the bloom filter 2024-09-15 14:28:49 -04:00
Vitor Pamplona
5921b712b0 Merge pull request #1082 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-13 14:17:46 -04:00
Crowdin Bot
5c9a37f01c New Crowdin translations by GitHub Action 2024-09-13 18:12:12 +00:00
Vitor Pamplona
514a69deec Bloom filter test case for NIP76 2024-09-13 14:06:45 -04:00
Vitor Pamplona
356cbc24d4 Merge pull request #1081 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-12 16:09:38 -04:00
Crowdin Bot
f4a2f247f7 New Crowdin translations by GitHub Action 2024-09-12 20:05:59 +00:00
Vitor Pamplona
9ee28e28b8 If already logged in and an NFC tag shows up with ncryptsec, logs in as a transient account 2024-09-12 15:57:35 -04:00
Vitor Pamplona
2457429470 Merge pull request #1078 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-12 08:15:27 -04:00
Crowdin Bot
b998381bb6 New Crowdin translations by GitHub Action 2024-09-12 12:04:40 +00:00
Vitor Pamplona
74725235b7 Merge pull request #1074 from davotoula/add-media-compression-level-uncompressed
Add media compression level uncompressed
2024-09-12 08:02:51 -04:00
David Kaspar
ef4d975c7c Merge branch 'vitorpamplona:main' into add-media-compression-level-uncompressed 2024-09-12 12:40:46 +01:00
Vitor Pamplona
3fdd3f9b69 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-11 17:15:33 -04:00
Vitor Pamplona
53aa9b0017 Trying to fix upload-artifact. 2024-09-11 17:15:23 -04:00
Vitor Pamplona
a52d87a555 Merge pull request #1076 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-11 17:15:10 -04:00
Crowdin Bot
39f062fe32 New Crowdin translations by GitHub Action 2024-09-11 21:03:20 +00:00
Vitor Pamplona
b2c13089ce adds NFC-Based transient accounts.
Refactors the login screen
2024-09-11 16:49:35 -04:00
David Kaspar
4ddf307b9f added translations 2024-09-11 17:27:08 +02:00
David Kaspar
3db36d8c67 added skipping of media compressor when quality is UNCOMPRESSED
added media compressor logging
2024-09-11 17:27:08 +02:00
David Kaspar
bba7d3455c enhanced nip05 logging 2024-09-11 17:27:08 +02:00
Vitor Pamplona
f086c0fe10 Merge pull request #1043 from greenart7c3/camera
Take pictures with camera
2024-09-10 15:32:01 -04:00
Vitor Pamplona
abc803953e Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-09 19:44:33 -04:00
Vitor Pamplona
8841aa9a10 Adds Tor 2024-09-09 19:42:43 -04:00
greenart7c3
7e417f09f7 Merge remote-tracking branch 'origin/camera' into camera 2024-09-09 16:10:47 -03:00
greenart7c3
0fde6ffd30 Move camera dependencies to version catalogs 2024-09-09 16:03:35 -03:00
greenart7c3
6f291139d8 Merge branch 'main' into camera 2024-09-09 15:40:56 -03:00
greenart7c3
1e3104d489 Add camera button in the stories screen 2024-09-09 15:37:30 -03:00
greenart7c3
2e110422c8 after enabling permissions open the camera 2024-09-09 14:59:05 -03:00
Vitor Pamplona
7b7aa1932f Merge pull request #1072 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-09 13:37:59 -04:00
Crowdin Bot
773c96997c New Crowdin translations by GitHub Action 2024-09-09 17:37:07 +00:00
Vitor Pamplona
8f8199f489 Merge pull request #1071 from greenart7c3/main
Fix edit draft not working when using the quick action menu
2024-09-09 13:33:54 -04:00
greenart7c3
a63d50c24f Merge remote-tracking branch 'origin/camera' into camera 2024-09-09 14:32:55 -03:00
greenart7c3
6af2b48d23 Add a button to take a picture from camera 2024-09-09 14:32:37 -03:00
greenart7c3
37197e1f8a Fix edit draft not working when using the quick action menu 2024-09-09 14:25:11 -03:00
Vitor Pamplona
8ede4eb3d6 Updates navigation compose, lifecycle, fragment, activity and composeBoms 2024-09-06 17:29:06 -04:00
Vitor Pamplona
c7fcc47b11 Adds an isRefreshing status for all feeds. 2024-09-06 17:19:10 -04:00
Vitor Pamplona
f12cf93e9f Merge pull request #1065 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-06 14:20:11 -04:00
Crowdin Bot
00e354394f New Crowdin translations by GitHub Action 2024-09-06 18:12:34 +00:00
Vitor Pamplona
bbb548fe21 Fixes the zap options available for the Zap the Devs button. 2024-09-06 11:36:01 -04:00
Vitor Pamplona
c19e1fc56d Fixes #1064 2024-09-06 11:19:01 -04:00
Vitor Pamplona
065cf83b68 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-06 11:00:04 -04:00
Vitor Pamplona
ca0da1dcc8 v0.91.0 2024-09-06 10:59:54 -04:00
Vitor Pamplona
58a5cbc624 Merge pull request #1063 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-06 10:12:13 -04:00
Crowdin Bot
554fef9eae New Crowdin translations by GitHub Action 2024-09-06 14:10:20 +00:00
Vitor Pamplona
7dd76f866a Enables the use of hidden words for all visible properties of the user 2024-09-06 10:07:41 -04:00
Vitor Pamplona
84bcc33e87 minor refactoring on variables inside the chat screen rendering. 2024-09-06 10:03:49 -04:00
Vitor Pamplona
6309aa1711 Fixes size of default banner when the profile is not loaded yet. 2024-09-06 10:02:04 -04:00
Vitor Pamplona
a8a3bc8a6b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-05 18:00:25 -04:00
Vitor Pamplona
d2b4a859bf Fixes Community Load up with Global filter 2024-09-05 18:00:17 -04:00
Vitor Pamplona
92d11d84a7 Merge pull request #1061 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-05 17:49:28 -04:00
Crowdin Bot
1cba6e6cdb New Crowdin translations by GitHub Action 2024-09-05 21:41:52 +00:00
Vitor Pamplona
20115c7080 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-05 17:39:51 -04:00
Vitor Pamplona
4b3d986c5b Moves has-new-item bottom navigation computations to use the new feed states and last read database in flows. 2024-09-05 17:39:36 -04:00
Vitor Pamplona
21112d3d46 Merge pull request #1060 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-05 15:23:45 -04:00
Crowdin Bot
ec3f307e0b New Crowdin translations by GitHub Action 2024-09-05 19:17:01 +00:00
Vitor Pamplona
e17cfb4fdb Simplifies the "and 2 more" translations 2024-09-05 15:11:00 -04:00
Vitor Pamplona
010305fbeb - Disables saving m3u8 files (streaming can't be saved)
- Changes Error messages to not include stack traces and move them to a copy to clipboard action instead.
2024-09-05 14:36:23 -04:00
Vitor Pamplona
5af473870e Fixes Search layout after the transition to nav 2024-09-05 14:00:39 -04:00
Vitor Pamplona
80f805617a Adds sliding animations in all inner screens 2024-09-05 13:53:52 -04:00
Vitor Pamplona
3e1184562f Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-05 13:47:33 -04:00
Vitor Pamplona
39edbd22cc fixes #1058 language update issue 2024-09-05 13:47:23 -04:00
Vitor Pamplona
c90e615d5b Merge pull request #1059 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-05 13:00:43 -04:00
Crowdin Bot
5e6e7a3b5f New Crowdin translations by GitHub Action 2024-09-05 16:08:57 +00:00
Vitor Pamplona
3e23d6e526 Merge pull request #1051 from davotoula/configurable-media-compression
Configurable media compression
2024-09-05 12:07:33 -04:00
David Kaspar
08054e226e Merge branch 'vitorpamplona:main' into configurable-media-compression 2024-09-05 16:02:52 +01:00
David Kaspar
cfe668ab0d move slider value label to above slider 2024-09-05 16:58:39 +02:00
Vitor Pamplona
8f19276fac Fixes blinking on crossfades when the system's light/dark theme is different than the app's theme. 2024-09-05 10:50:16 -04:00
Vitor Pamplona
3a3c0d74d2 Fixes the Messages layout on foldables. 2024-09-05 10:30:58 -04:00
David Kaspar
3ad21e72a9 Clean up by using Int instead of Float for compression level 2024-09-05 16:10:26 +02:00
David Kaspar
790b9359bd Merge branch 'vitorpamplona:main' into configurable-media-compression 2024-09-05 14:32:59 +01:00
David Kaspar
e52e2bfdc9 Localise and add czech, swedish, german and PT translations 2024-09-05 15:09:19 +02:00
Vitor Pamplona
a34c763656 Merge pull request #1056 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-05 08:42:51 -04:00
Crowdin Bot
adf5bbe80f New Crowdin translations by GitHub Action 2024-09-05 12:39:02 +00:00
Vitor Pamplona
c4c542c739 Moves the last npub to hexkey for https://github.com/nostr-protocol/nips/pull/1341 2024-09-05 08:37:15 -04:00
David Kaspar
5f0236c015 change MediaCompressor API: mandatory mediaQuality 2024-09-05 14:28:12 +02:00
David Kaspar
023c0592e8 Add compression configuration to VideoScreen uploads 2024-09-05 13:09:17 +02:00
David Kaspar
c6d2f05f15 Styling 2024-09-05 11:50:52 +02:00
David Kaspar
08e9f6625a Merge branch 'main' into configurable-media-compression 2024-09-05 08:32:49 +01:00
David Kaspar
fcf5e972d6 Passing media quality from Slider to Compressor
setting media quality to MEDIUM where ui has no option for setting it (ChannelScreen, ChatroomScreen, NewUserMetadataViewModel)
2024-09-05 09:09:10 +02:00
Vitor Pamplona
20bf4dbc29 Merge pull request #1053 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-04 22:28:12 -04:00
Crowdin Bot
990c5df2b9 New Crowdin translations by GitHub Action 2024-09-05 02:21:58 +00:00
Vitor Pamplona
71af88725a Fixes play version 2024-09-04 22:19:28 -04:00
Vitor Pamplona
fb5f5c3eba no message 2024-09-04 22:18:00 -04:00
Vitor Pamplona
ead588314e - Inverts Layouts to place Navigation on top of Scaffold and allow custom scaffolds per route.
- Finishes Edge to Edge transition
- Fixes moving buttons on the full screen dialog for images and videos.
- Restructures screens into their own packages.
- Restructures navigation functions as a single object
- Fixes padding of the backup screen
- Refactors all TopBars to use default material 3 ones.
- Refactors navigation to improve clarity
- Removes unnecessary observers from the transition in the bottom nav layouts.
2024-09-04 22:17:42 -04:00
Vitor Pamplona
fb447e441a Merge pull request #1052 from greenart7c3/gcompatup
Fix token sanitization when using gcompatup
2024-09-04 13:37:42 -04:00
greenart7c3
3d61343905 Fix token sanitization when using gcompatup 2024-09-04 13:58:55 -03:00
David Kaspar
7e4d9fb708 added media quality slider 2024-09-04 12:32:33 +02:00
Vitor Pamplona
0217fd3eb3 Moves the profile zap to threads 2024-09-03 17:29:41 -04:00
Vitor Pamplona
96fa6f39b0 Fixes ime padding issues when using physical keyboards on the message screens. 2024-09-03 17:28:07 -04:00
Vitor Pamplona
b6bb214532 Fixes text field recompositions because of new keyboard actions 2024-09-03 15:55:52 -04:00
Vitor Pamplona
ee160b76a8 Updates zoomable and kotlin serialization 2024-09-03 15:54:54 -04:00
Vitor Pamplona
2fd9d6610a Normalizes cache directories in the Application class 2024-09-03 15:31:16 -04:00
David Kaspar
272f99be17 added logging 2024-09-03 20:44:39 +02:00
David Kaspar
b8246d7cd4 fixing type 2024-09-03 19:23:47 +02:00
David Kaspar
981d93f2fc making MediaCompressor configurable 2024-09-03 19:18:10 +02:00
Vitor Pamplona
1166fe92e9 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-03 11:45:27 -04:00
Vitor Pamplona
b700d4e9bf Fixes missing Private Home/Outbox relay list after loading from backup 2024-09-03 11:43:17 -04:00
Vitor Pamplona
0b41d09fda Merge pull request #1050 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-02 16:00:29 -04:00
Crowdin Bot
61c145a68f New Crowdin translations by GitHub Action 2024-09-02 19:29:46 +00:00
Vitor Pamplona
4103342f99 Updates to AGP 8.6.0 2024-09-02 15:24:39 -04:00
Vitor Pamplona
e48ed607ff Fixes #1049 2024-09-02 14:56:24 -04:00
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 Pamplona
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 Pamplona
064e211154 Merge pull request #1045 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-02 09:59:23 -04:00
Vitor Pamplona
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 Pamplona
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 Pamplona
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 Pamplona
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 Pamplona
b733fbce35 Merge pull request #1036 from Dawn11111111/patch-1
Typo Fix on Lnaddress TextField
2024-08-30 12:05:00 -04:00
Dawn
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 Pamplona
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 Pamplona
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 Pamplona
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
Vitor Pamplona
42ab826d02 v0.90.2 2024-08-27 15:01:50 -04:00
Vitor Pamplona
80a3f7424b fixes conversation tab 2024-08-27 14:52:22 -04:00
Vitor Pamplona
1bd3e347b3 Updates media 3 dependencies. 2024-08-27 14:51:30 -04:00
Vitor Pamplona
a332126e71 Quick fix for to not show error messages on Zap the Devs 2024-08-27 14:33:57 -04:00
Vitor Pamplona
d97abe6af9 v0.90.0 2024-08-27 13:48:36 -04:00
Vitor Pamplona
62d341bd8a Publishing new user's events 2024-08-27 11:49:43 -04:00
Vitor Pamplona
a95d2e903e Merge branch 'main' of https://github.com/vitorpamplona/amethyst
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeDrawer.kt
2024-08-27 11:04:36 -04:00
Vitor Pamplona
2b5e62aaf7 - Improves rendering of the QR Code elements
- Makes the QR bigger
- Moves QR Code to rounder corners
- Moves QR Screen to use the arrow icon instead of the close button
2024-08-27 11:04:13 -04:00
Vitor Pamplona
1c3e82913f Merge pull request #1031 from greenart7c3/main
Use white background for qrcodes
2024-08-27 11:02:33 -04:00
Vitor Pamplona
ceaa20fabf Removes galleries live data 2024-08-27 08:34:25 -04:00
Vitor Pamplona
1bbabb747e Deleting Bundled updates for Account. 2024-08-26 19:12:39 -04:00
Vitor Pamplona
914c0a2926 Making sure scopes are cancelled for accountViewModel notification dot updates. 2024-08-26 19:12:28 -04:00
Vitor Pamplona
4b249156f9 Makes sure the accountViewModel is deleted when the user switches the screen, logs off or kills the activity. 2024-08-26 18:22:34 -04:00
Vitor Pamplona
7b8c7a1a78 Simplifies flow collection for new events. 2024-08-26 18:22:12 -04:00
Vitor Pamplona
66ed26dcea Finishes the migration of the service manager to the Application class 2024-08-26 18:21:53 -04:00
Vitor Pamplona
356740479c Moves to hold the feed itself in a stateflow 2024-08-26 17:48:27 -04:00
Vitor Pamplona
c88dded01b Improves stability of composables 2024-08-26 16:45:07 -04:00
Vitor Pamplona
7c974bc8ef adds a directory for metrics as well 2024-08-26 15:27:04 -04:00
Vitor Pamplona
efbc3e3e78 removes unecessary logs 2024-08-26 14:57:16 -04:00
Vitor Pamplona
fac803e208 Merge pull request #1032 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-26 14:56:04 -04:00
Crowdin Bot
484a3d6b32 New Crowdin translations by GitHub Action 2024-08-26 18:44:35 +00:00
Vitor Pamplona
4e3b6d0299 - Adds a sync Signer to facilitate library
- Separates Account actions from Account state in two objects
- Changes Startup procedures to start with Account state and not the full account object
- Moves scope for flows in Account from an Application-wide scope to ViewModel scope
- Removes all LiveData objects from Account in favor of flows from the state object
- Migrates settings saving logic to flows
- Migrates PushNotification services to work without Account and only Account Settings.
- Migrates the spam filter from LiveData to Flows
- Adds Default lists for NIP-65 inbox and outbox relays
- Adds Default lists for Search relays
- Adds local backup for UserMetadata objects
- Adds local backup for Mute lists
- Adds local backup for NIP-65 relays
- Adds local backup for DM Relays
- Adds local backup for private home relays
- Rewrites state flows initializers to avoid inconsistent startups
2024-08-26 14:42:55 -04:00
Vitor Pamplona
f3161ada8d Solves crash when opening the Relay screen with empty urls as relays. 2024-08-26 11:13:10 -04:00
greenart7c3
5c9b84476b Use white background for qrcodes 2024-08-26 07:21:19 -03:00
Vitor Pamplona
24ea0cabda Updates benchmark, composeBOM and firebaseBOM to the latest versions 2024-08-23 08:35:44 -04:00
Vitor Pamplona
8b617fbd60 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-22 17:27:04 -04:00
Vitor Pamplona
34149e6ce2 Merge pull request #1030 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-22 17:13:16 -04:00
Vitor Pamplona
315a662c10 Improve horizontal padding of the chat messages 2024-08-22 17:12:46 -04:00
Crowdin Bot
c373843e0f New Crowdin translations by GitHub Action 2024-08-22 21:03:22 +00:00
Vitor Pamplona
448ea796ef Improves caching of encrypted DMs:
- Migrates caching of decrypted value outside of the Event class
- Removes encrypted parts of NIP-17 from the cache
- Removes old NIP-04 messages from the cache
- Avoids deleting new NIP-17 plain text chats from memory
2024-08-22 17:01:37 -04:00
Vitor Pamplona
71b111ce8b Fixes the download of 1000s of NIP-65 relay lists because some relays consider empty lists as null and return everything. 2024-08-22 12:24:45 -04:00
Vitor Pamplona
ae686671ba Minimizing the number of filters for group chat metadata 2024-08-22 09:55:28 -04:00
Vitor Pamplona
6c45bb1ca1 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-21 20:32:06 -04:00
Vitor Pamplona
a05ad6e686 Adds support for NIP-35 torrents and their comments 2024-08-21 20:31:59 -04:00
Vitor Pamplona
485f7becd4 Merge pull request #1029 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-21 19:18:29 -04:00
Vitor Pamplona
83f1e523ea Creates links to njump when events can't be found on Amethyst 2024-08-21 17:05:14 -04:00
Vitor Pamplona
a8a3c94115 clear flows before removing notes from the local cache 2024-08-21 16:32:11 -04:00
Vitor Pamplona
4d21857adf moves the processing of nip19 links to the default thread 2024-08-21 16:31:57 -04:00
Crowdin Bot
0e7f41efe8 New Crowdin translations by GitHub Action 2024-08-21 20:09:17 +00:00
Vitor Pamplona
8d9bf60dba Merge pull request #1028 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-21 16:08:51 -04:00
Vitor Pamplona
7c67924914 fixes imports 2024-08-21 16:07:42 -04:00
Crowdin Bot
7626839037 New Crowdin translations by GitHub Action 2024-08-21 19:59:14 +00:00
Vitor Pamplona
23825ceed5 Avoids bottom nav recompositions 2024-08-21 15:57:45 -04:00
Vitor Pamplona
8d8016e01b Simplifies nav bar selected calculations 2024-08-21 15:51:01 -04:00
Vitor Pamplona
6668e5a178 Moves accountViewModelMock to a more appropriate file 2024-08-21 15:14:28 -04:00
Vitor Pamplona
7d6bef9576 Moves heavy computations away from IO 2024-08-21 15:00:13 -04:00
Vitor Pamplona
1aea7fbd03 Avoids remembering edit lists that will never exist. 2024-08-21 14:58:01 -04:00
Vitor Pamplona
ef96d296f8 avoids double update to the viewmodel 2024-08-21 14:53:35 -04:00
Vitor Pamplona
d4da58e14a Improves logs when the UserMetadata cannot be parsed 2024-08-21 14:38:17 -04:00
Vitor Pamplona
f646866192 moves markasread from io to default 2024-08-21 09:20:43 -04:00
Vitor Pamplona
e0849b12e7 Merge pull request #1027 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-21 07:55:58 -04:00
Crowdin Bot
1271343582 New Crowdin translations by GitHub Action 2024-08-21 11:52:51 +00:00
Vitor Pamplona
d61d14ea75 Merge pull request #1026 from davotoula/chage-twitter-to-x
Change twitter proof to use x.com
2024-08-21 07:51:04 -04:00
David Kaspar
668d604aa9 use original twitter colour in x.xml because I can't get the theme switching working 2024-08-21 11:56:45 +01:00
Vitor Pamplona
6df2c3a0cf improving speed of chatlist rendering 2024-08-20 15:23:15 -04:00
Vitor Pamplona
b0c33808e6 Adds support for MOD reports 2024-08-20 15:07:28 -04:00
Vitor Pamplona
7bd3e0beab Improved padding and spacing for chat messages 2024-08-20 13:05:44 -04:00
Vitor Pamplona
3a70cfb9e2 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-20 09:14:56 -04:00
Vitor Pamplona
17c758f8f8 Displays PoW of commitment if present. 2024-08-20 09:14:47 -04:00
Vitor Pamplona
7cb03b939f Merge pull request #1022 from KotlinGeekDev/fix-relay-set
Fix relay set event.
2024-08-20 08:57:34 -04:00
KotlinGeekDev
481ef91cb1 Change relay set kind to be NIP-51 consistent. 2024-08-20 12:13:42 +01:00
Vitor Pamplona
1183aa8d11 Changes fdroid push registration to re-register even if the saved distributor was already selected. 2024-08-19 17:58:50 -04:00
Vitor Pamplona
4023261400 Merge pull request #1020 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-19 12:07:35 -04:00
Crowdin Bot
f1433504e9 New Crowdin translations by GitHub Action 2024-08-19 16:00:50 +00:00
Vitor Pamplona
989cbb35d3 Disabling landscape rotation on full screen due to portrait videos. 2024-08-19 11:58:50 -04:00
Vitor Pamplona
9262b90096 Merge pull request #1019 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-19 11:40:22 -04:00
Crowdin Bot
ab3b7685cf New Crowdin translations by GitHub Action 2024-08-19 15:40:02 +00:00
Vitor Pamplona
40865ac97d Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-19 11:36:39 -04:00
Vitor Pamplona
ded67964b0 Merge pull request #1018 from KotlinGeekDev/fullscreen-video-behavior
Change tap to fullscreen video behavior for livestreams.
2024-08-19 11:34:47 -04:00
Vitor Pamplona
e991707ddf updates runner 2024-08-19 11:32:04 -04:00
KotlinGeekDev
1b429475aa Remove the manual testing/debugging code. Remove the LocalConfiguration API in favour of DeviceUtils.isLandscapeMetric. 2024-08-17 18:51:07 +01:00
KotlinGeekDev
9613afb7ea Fix ZoomableImageDialog. isFoldableOrLarge already takes into account the orientation. 2024-08-17 17:07:43 +01:00
KotlinGeekDev
26a12f2dbe Remove the external config method and use the built-in Compose API.
Add some documentation.
2024-08-17 17:03:07 +01:00
KotlinGeekDev
23da02f932 Merge remote-tracking branch 'origin/fullscreen-video-behavior' into fullscreen-video-behavior 2024-08-17 15:19:47 +01:00
KotlinGeekDev
59ff9007a7 Move DeviceUtils closer to call-site. Copy code from Material3-Adaptive library(to avoid experimental annotation propagation everywhere).
Implement windowIsLarge to take care of device type handling(sort-of). Add some funky debug methods to make sure windowIsLarge works.
2024-08-17 14:18:48 +00:00
KotlinGeekDev
0f817dbf00 Put device in landscape mode when fullscreen button is pressed. 2024-08-17 14:18:48 +00:00
KotlinGeekDev
7ab6322886 Introduce DeviceUtils, and add functions for changing orientation, and checking for Landscape mode. 2024-08-17 14:18:48 +00:00
KotlinGeekDev
223c0c1263 Initial implementation for auto-fullscreen on rotation to landscape mode. 2024-08-17 14:18:48 +00:00
KotlinGeekDev
d3a639a2ee Consistent naming and behavior for the video player controls. 2024-08-17 14:18:48 +00:00
KotlinGeekDev
2fb1cbe11f Move DeviceUtils closer to call-site. Copy code from Material3-Adaptive library(to avoid experimental annotation propagation everywhere).
Implement windowIsLarge to take care of device type handling(sort-of). Add some funky debug methods to make sure windowIsLarge works.
2024-08-17 15:14:24 +01:00
Vitor Pamplona
29c5b45764 Moves the default zap type to a state flow and avoids passing on to the screen when using the default value. 2024-08-16 17:45:53 -04:00
Vitor Pamplona
ec83ad9d20 Removing unecessary livedata objects for translation services 2024-08-16 17:10:02 -04:00
Vitor Pamplona
239f973e89 Moves lastread routes to mutableStateFlow 2024-08-16 16:42:00 -04:00
Vitor Pamplona
6c81fbbcf9 Fixes crash when the relay url of a user is duplicated. 2024-08-16 16:00:03 -04:00
Vitor Pamplona
75e45f37b2 Migrating livedata to flow for contact list updates of the user. 2024-08-16 15:59:43 -04:00
Vitor Pamplona
0bff3462b1 Migrates the pubkeyHex from keypair to the signer. 2024-08-16 15:58:23 -04:00
Vitor Pamplona
990db68a4d Fixes spacing of short quotes 2024-08-16 13:12:05 -04:00
Vitor Pamplona
4343c744db Displaying stacks in error messages if they are present 2024-08-16 11:14:34 -04:00
Vitor Pamplona
5cb6f97178 linting cleanup 2024-08-16 10:48:30 -04:00
KotlinGeekDev
a8efc9d348 Put device in landscape mode when fullscreen button is pressed. 2024-08-16 14:53:40 +01:00
Vitor Pamplona
e0b667fd2d Fixes order for the notification feed after refactoring 2024-08-16 09:41:38 -04:00
Vitor Pamplona
a8ad2437fe refactors the Watch feed to scroll to the top 2024-08-16 09:39:15 -04:00
KotlinGeekDev
83018e621e Introduce DeviceUtils, and add functions for changing orientation, and checking for Landscape mode. 2024-08-16 14:19:55 +01:00
Vitor Pamplona
68b15ba483 Refactors old user zap object. 2024-08-15 17:08:15 -04:00
Vitor Pamplona
7513036d47 Refactors sorting of Notifications 2024-08-15 16:16:36 -04:00
Vitor Pamplona
c82c594926 Fixes slow down when the last message on chat is a base64 image. 2024-08-15 16:02:28 -04:00
Vitor Pamplona
bd18d4b4b2 Adjusts Outbox filter as soon as a new NIP-65 relay list shows up 2024-08-15 15:17:41 -04:00
Vitor Pamplona
2f5b99a1c7 Merge pull request #1016 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-15 15:06:50 -04:00
Crowdin Bot
1be6e0ec0f New Crowdin translations by GitHub Action 2024-08-15 18:58:39 +00:00
Vitor Pamplona
f43c0e5cf5 Moves error messages from uploading procedures to the full error dialog instead of just a toast. 2024-08-15 14:55:25 -04:00
Vitor Pamplona
d6d8719c8d Fixes the centralization of the "and more" part of relay recommendations 2024-08-14 18:50:26 -04:00
Vitor Pamplona
7478899388 Fixes miscaching flows of the relay lists from follows. 2024-08-14 18:03:25 -04:00
Vitor Pamplona
6b193894bd Fixes miscache of hashtag following button 2024-08-14 17:16:02 -04:00
Vitor Pamplona
69f5371ab8 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-14 17:15:36 -04:00
Vitor Pamplona
526e3db3c1 Adds more information to when error messages are not available in the relay stats. 2024-08-14 15:50:05 -04:00
Vitor Pamplona
3c9e8854eb Merge pull request #1015 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-14 15:14:42 -04:00
Crowdin Bot
7ee0aa47a9 New Crowdin translations by GitHub Action 2024-08-14 19:07:04 +00:00
Vitor Pamplona
5fbf0462fa No need to check for the MainThread in every updateFeedWith 2024-08-14 15:04:51 -04:00
Vitor Pamplona
eda5bc50e6 Adds a destroy method to FollowList state for consistency 2024-08-14 10:45:57 -04:00
David Kaspar
d6d26d466b clean up code in MetadataEvent.kt
update x drawable
2024-08-14 08:11:28 +01:00
Vitor Pamplona
6acd3ca091 Moves follow list states to the AccountViewModel 2024-08-13 19:35:04 -04:00
Vitor Pamplona
c64d179f7f Migrates Notification Summary to the new state model 2024-08-13 18:56:53 -04:00
Vitor Pamplona
32bc8fe667 Fixes f-droid flavor after refactoring 2024-08-13 18:35:31 -04:00
Vitor Pamplona
0a8c792d53 Moves the notification screen to the new state model instead of viewModels 2024-08-13 18:32:20 -04:00
Vitor Pamplona
c32dc61ea1 Refactoring Moving feed status from ViewModel to State objects 2024-08-13 17:32:15 -04:00
Vitor Pamplona
3af2cb52cc adds a prune for giftwrapped messages 2024-08-13 15:11:15 -04:00
Vitor Pamplona
ddb1d7ff82 Adds context to highlight events 2024-08-13 15:08:32 -04:00
KotlinGeekDev
fd0523085d Initial implementation for auto-fullscreen on rotation to landscape mode. 2024-08-13 10:42:49 +01:00
Vitor Pamplona
1f9fbb0c86 Revers letter spacing in inline code on Markdow 2024-08-12 15:58:17 -04:00
Vitor Pamplona
5b95a1c177 Improved logs when clearing the cache for DMs 2024-08-12 15:55:25 -04:00
Vitor Pamplona
2e144ebdf8 Adds previews to test markdown rendering 2024-08-12 15:55:01 -04:00
Vitor Pamplona
ac5f550ff3 Fixes heading sizes on markdown 2024-08-12 15:54:20 -04:00
Vitor Pamplona
9af123b709 Improves the look of inlinde code in markdown 2024-08-12 15:53:45 -04:00
Vitor Pamplona
75efd2b372 Improves badge display 2024-08-12 11:18:22 -04:00
Vitor Pamplona
2cface9ac9 Inverts spam logs 2024-08-12 11:11:23 -04:00
Vitor Pamplona
58491a97ac Merge pull request #1012 from greenart7c3/main
show only 3 users in the recommended relays section
2024-08-12 09:07:21 -04:00
greenart7c3
7a86d78c50 show only 3 users in the recommended relays section 2024-08-12 09:05:08 -03:00
David Kaspar
97a00ff83c First pass updating Twitter verification to X 2024-08-12 11:07:48 +02:00
Vitor Pamplona
4c6842faf4 v0.89.10 2024-08-09 18:47:48 -04:00
Vitor Pamplona
fad8b9df0f Improved filter for notifications 2024-08-09 18:47:38 -04:00
Vitor Pamplona
cc52681635 Moves service manager to the Application class 2024-08-09 18:14:09 -04:00
Vitor Pamplona
939c8e95b0 Adds protections against filters with empty arrays because some relays consider that null as opposed to empty. 2024-08-09 18:13:19 -04:00
Vitor Pamplona
312501e527 AGP to 8.5.2 2024-08-09 15:25:52 -04:00
Vitor Pamplona
89638ff261 Deletes all events in maximum chunks of 200 elements to avoid the 65KB stringified JSON limit of many relays. 2024-08-09 14:35:00 -04:00
Vitor Pamplona
27d2e2309a Fixes the clickable regions to open additional relay info on the relay list dialog. 2024-08-09 13:44:44 -04:00
Vitor Pamplona
7eaa553ebe Allows users to select and copy the notice from the relay on the relay list dialog 2024-08-09 13:43:53 -04:00
KotlinGeekDev
8adddd0bdb Consistent naming and behavior for the video player controls. 2024-08-09 15:26:42 +01:00
Vitor Pamplona
d8f084f886 Merge pull request #1009 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-08 18:37:10 -04:00
Crowdin Bot
5c5a23dfbb New Crowdin translations by GitHub Action 2024-08-08 22:30:34 +00:00
Vitor Pamplona
282d4614c0 Adds zap amount cache for the memory space calculations 2024-08-08 18:19:48 -04:00
Vitor Pamplona
8ed78ea38f Marks username as deprecated 2024-08-08 18:18:54 -04:00
Vitor Pamplona
2e7b7c5c00 improves wording on the name of relays 2024-08-08 14:35:45 -04:00
Vitor Pamplona
68cad97819 Reducing the amount of CPU memory used for images to the default. 2024-08-08 11:51:35 -04:00
Vitor Pamplona
02f7b54661 Updates translate dependencies 2024-08-08 08:31:38 -04:00
Vitor Pamplona
e887232132 v0.89.9 2024-08-07 18:39:02 -04:00
Vitor Pamplona
7bcf4e3750 Removes logs from previous commit 2024-08-07 18:34:38 -04:00
Vitor Pamplona
ed0676a5f5 Fixes the order of bookmarks (keeps the order of the event, instead of the created at) 2024-08-07 18:24:35 -04:00
Vitor Pamplona
f731c654b0 Improves the async rendering of Base64 content 2024-08-07 18:24:14 -04:00
Vitor Pamplona
b026bffe4a Moves discovery and video lists to Outbox when Follows or relay lists are selected 2024-08-07 16:39:24 -04:00
Vitor Pamplona
a1aaec0b7d Adds support for selecting authors based on their Outbox relays when searching for notes authored by them 2024-08-07 15:46:27 -04:00
Vitor Pamplona
6f59097ac0 Aligns default note comparator to NIP-01's created at descending and then by id ascending 2024-08-07 15:44:35 -04:00
Vitor Pamplona
263df0c895 Renames PerRelayFilter to SincePerRelay
Creates PerRelay interface to allow other types of filters
2024-08-07 15:44:03 -04:00
Vitor Pamplona
1ca5713494 Merge pull request #1008 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-07 12:57:34 -04:00
Crowdin Bot
b706c4e871 New Crowdin translations by GitHub Action 2024-08-07 16:04:54 +00:00
Vitor Pamplona
e8574c10bb Keep them public to allow testing in these particular functions 2024-08-07 12:02:48 -04:00
Vitor Pamplona
7cb87ea9c5 Refactors to use native contains instead of custom lambdas on Ammolite's Filter 2024-08-07 11:58:00 -04:00
Vitor Pamplona
07e5132943 Refactors Ammolite Filters to be regular ones and creates a PerRelayFilter for the use on Amethyst. 2024-08-07 11:40:01 -04:00
Vitor Pamplona
2b1e3cfc93 Renames the MinimumRelayList to RecommendationProcessor 2024-08-07 11:39:14 -04:00
Vitor Pamplona
c17e1f0e1f removes logs from previous commit 2024-08-06 17:14:22 -04:00
Vitor Pamplona
e88d1d0d00 Adds haptic feedback to draft deletion swipe 2024-08-06 17:13:06 -04:00
Vitor Pamplona
5fdff97cf8 Moves the ContactList cache lists to AccountViewModel, where it can be disposed more efficiently. 2024-08-06 15:36:04 -04:00
Vitor Pamplona
e5328d7975 Improves the accuracy of the Event memory counter. 2024-08-06 15:35:14 -04:00
Vitor Pamplona
71b45b96fb Adds event factory performance test 2024-08-06 11:04:30 -04:00
Vitor Pamplona
dee28f3aa8 Merge pull request #1006 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-05 17:26:13 -04:00
Crowdin Bot
3a118bb939 New Crowdin translations by GitHub Action 2024-08-05 20:59:07 +00:00
Vitor Pamplona
971c92b27a Adds extension possibility to Quartz's event factory 2024-08-05 16:57:21 -04:00
Vitor Pamplona
4c70065843 Moves DataSource dispatcher from IO to Default 2024-08-05 16:02:35 -04:00
Vitor Pamplona
eab69337ff Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-05 16:02:19 -04:00
Vitor Pamplona
ab772eb65e Makes stringRes Stable for compose 2024-08-05 15:48:06 -04:00
Vitor Pamplona
7a6720a7ab Merge pull request #1005 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-05 15:31:47 -04:00
Crowdin Bot
0f3dc18f7a New Crowdin translations by GitHub Action 2024-08-05 19:16:01 +00:00
Vitor Pamplona
84df86f4b6 Removes mutiny NWC button :( 2024-08-05 15:13:35 -04:00
Vitor Pamplona
a0289a0cb4 Moves Relay viewModels to Default thread 2024-08-05 15:08:08 -04:00
Vitor Pamplona
3c034ca8af v0.89.8 2024-08-02 16:34:51 -04:00
Vitor Pamplona
0c8c3003aa Merge pull request #1004 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-02 13:37:05 -04:00
Crowdin Bot
919dabfefa New Crowdin translations by GitHub Action 2024-08-02 16:39:19 +00:00
Vitor Pamplona
fd06193e93 Notifies relay failures after marking them as closed. 2024-08-02 12:36:54 -04:00
Vitor Pamplona
b02fa43628 Print relay writes on benchmark as well. 2024-08-02 12:35:52 -04:00
Vitor Pamplona
7fdf38d17b Normalizes logs for the DataSources 2024-08-02 12:35:25 -04:00
Vitor Pamplona
63e036a9bb Minimizes relay updates when rotating status events for each user. 2024-08-02 12:32:44 -04:00
Vitor Pamplona
fc98442f8b Adds support for CashuB tokens using CBOR 2024-07-31 17:36:48 -04:00
Vitor Pamplona
3bbb780d2b Simplifies the drawing of gallery entires without e tags. 2024-07-31 16:02:18 -04:00
Vitor Pamplona
42ceb0c404 Fixes test case for ImageUploaders 2024-07-31 15:59:07 -04:00
Vitor Pamplona
046bd7a0e9 Merge pull request #1003 from greenart7c3/main
Add delete all drafts button
2024-07-31 14:58:26 -04:00
Vitor Pamplona
0af0f745bf Fixes the need for main thread note loading for galleries 2024-07-31 14:57:44 -04:00
Vitor Pamplona
b24999d78c Basic refactoring to minimize the need for user-space params in the VideoView 2024-07-31 14:43:10 -04:00
Vitor Pamplona
d1278a4477 Better rendering for live streams that are planned but already finished. 2024-07-31 14:16:44 -04:00
greenart7c3
609ccaa635 Add delete all drafts button 2024-07-31 13:51:02 -03:00
Vitor Pamplona
4bae04ad31 Merge pull request #1001 from believethehype/better_gallery_view
Gallery: use FileHeader Info for rendering / faster loading
2024-07-31 10:41:19 -04:00
Believethehype
8bca729edd Merge remote-tracking branch 'upstream/main' into better_gallery_view 2024-07-31 16:36:25 +02:00
Vitor Pamplona
2b2f04f724 Adds the callbackUri to the display uncited hashtags so that it can reuse the previously parsed post. 2024-07-31 10:21:13 -04:00
Vitor Pamplona
231af1d3d8 Fixes #993 2024-07-31 10:17:19 -04:00
Believethehype
d1b44c0ac0 remove print 2024-07-31 12:59:23 +02:00
Believethehype
f06c3f7376 massive speed up of rendering the gallery 2024-07-31 12:55:02 +02:00
Believethehype
b8e10b040d cleanup 2024-07-31 12:02:39 +02:00
Believethehype
1a4396dbe3 removes unneded variable 2024-07-31 11:51:44 +02:00
Believethehype
856eb0da05 create GalleryFeed earlier 2024-07-31 11:40:59 +02:00
Believethehype
ceaeb8e0b9 use GalleryContentView instead of 2024-07-31 11:24:05 +02:00
Vitor Pamplona
b29ccd9746 Refactors the lambda used for content resolvers. 2024-07-30 18:16:28 -04:00
Vitor Pamplona
f82364939f Adds icons in SVG to the docs 2024-07-30 18:16:06 -04:00
Vitor Pamplona
286583bba2 fixing the need for a new coroutine startup 2024-07-30 13:50:08 -04:00
Vitor Pamplona
bc8e5aa458 Merge pull request #997 from VASHvic/fix-clipboard-copy-edited-notes
Fix copying to clipboard edited notes
2024-07-30 13:47:49 -04:00
Vitor Pamplona
b393fb042f Merge pull request #999 from believethehype/nutrendering
Show mint in cashu token rendering
2024-07-30 13:47:14 -04:00
Vitor Pamplona
0258c5aac2 Releasing Flows if not subscribed anymore. 2024-07-30 13:22:48 -04:00
Vitor Pamplona
a03a11c4cc Updates depedencies 2024-07-30 13:17:53 -04:00
Vitor Pamplona
3896bbab34 Merge pull request #1000 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-30 09:17:19 -04:00
Crowdin Bot
5459f31000 New Crowdin translations by GitHub Action 2024-07-29 23:24:58 +00:00
Vitor Pamplona
143c3a1444 Testing that both 02 and 03 compressed keys are valid Nostr keys and can encrypt and decrypt to each other. 2024-07-29 19:23:22 -04:00
Believethehype
bfbcf11fa3 Update CashuRedeem.kt 2024-07-29 20:42:17 +02:00
VASH
25823b1f39 Fix copying to clipboard edited notes 2024-07-27 13:05:20 +02:00
Believethehype
cb02955ac6 fix order of Share ImageAction 2024-07-24 07:49:44 +02:00
Vitor Pamplona
b440661e0d Adds the highlight to the base URL so that when the user opens the link, it highlights on the page. 2024-07-23 12:56:53 -04:00
Vitor Pamplona
38ba456a22 Signs for just one auth event to register with the push notification service instead of the dozens of events, one per relay. 2024-07-23 10:15:36 -04:00
Vitor Pamplona
bca941e045 enables crossfading between images 2024-07-23 08:47:20 -04:00
Vitor Pamplona
dbaadb4595 v0.89.7 2024-07-22 18:10:15 -04:00
Vitor Pamplona
231ca44e1c Avoids recomposition of the entire line when changing status. 2024-07-22 18:07:44 -04:00
Vitor Pamplona
ca00c87a6e Prints memory use in descending order by size 2024-07-22 16:59:58 -04:00
Vitor Pamplona
2a28111583 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-07-22 14:27:23 -04:00
Vitor Pamplona
4def80c914 updates the EOSE of users even if no user is found in the relay. 2024-07-22 14:27:10 -04:00
Vitor Pamplona
916ece1edb Merge pull request #992 from greenart7c3/main
Remove focus from status text field when closing the drawer
2024-07-22 14:00:24 -04:00
Vitor Pamplona
3c1f57fac0 Sorts keys before making the filter. 2024-07-22 13:57:31 -04:00
greenart7c3
e166c36731 Remove focus from status text field when closing the drawer 2024-07-22 14:45:39 -03:00
Vitor Pamplona
a636f4a786 updates coil and jackson libs 2024-07-22 11:31:57 -04:00
Vitor Pamplona
bb127506c7 Merge pull request #985 from believethehype/main
Store additional File Metadata in Gallery Entries
2024-07-22 08:10:26 -04:00
Vitor Pamplona
92c704d227 Merge pull request #986 from greenart7c3/main
Add sendAndWaitForResponse to Client.kt
2024-07-22 08:10:11 -04:00
greenart7c3
e764ebbcb2 if the relay just connected send the event 2024-07-22 07:49:54 -03:00
greenart7c3
68fb34a8ad Merge branch 'main' into main 2024-07-22 07:36:43 -03:00
greenart7c3
02683ba829 Merge remote-tracking branch 'origin/main' 2024-07-22 07:34:36 -03:00
greenart7c3
754a1b3a87 change the log of onRelayStateChange 2024-07-22 07:33:21 -03:00
Vitor Pamplona
4b58da7be7 Merge pull request #991 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-21 15:22:31 -04:00
Crowdin Bot
1ac9c850e4 New Crowdin translations by GitHub Action 2024-07-21 18:32:48 +00:00
Vitor Pamplona
3cc4dc1414 Merge pull request #990 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-21 14:31:54 -04:00
Vitor Pamplona
62ae43e863 v0.89.6 2024-07-21 14:31:17 -04:00
Crowdin Bot
8149a93f2f New Crowdin translations by GitHub Action 2024-07-21 18:31:01 +00:00
Vitor Pamplona
5a60c3a595 Fixes stringResource cache 2024-07-21 14:29:05 -04:00
Vitor Pamplona
2ef2f0d1a5 adds the key to avoid applying to a separate navcontroller 2024-07-21 11:32:08 -04:00
Vitor Pamplona
52fef33662 run faster animations 2024-07-21 11:18:56 -04:00
Vitor Pamplona
a5c6f86b35 v0.89.5 2024-07-20 16:27:27 -04:00
Vitor Pamplona
b80008d695 Adds more memory stat dumping for production apps 2024-07-20 16:27:19 -04:00
Vitor Pamplona
fa06aefbf1 increases disk cache to 1GB and adds a memory cache policy. 2024-07-20 15:32:34 -04:00
Vitor Pamplona
ab9ede1e26 Merge pull request #987 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-20 15:08:00 -04:00
Crowdin Bot
479b1ab20a New Crowdin translations by GitHub Action 2024-07-20 19:04:48 +00:00
Vitor Pamplona
8ed3f84cb7 Fixes screen rendering below the 3-button navigation 2024-07-20 15:02:57 -04:00
Vitor Pamplona
c128e43fe5 v0.89.4 2024-07-19 17:10:22 -04:00
Vitor Pamplona
f9ddf76686 action bar fix 2024-07-19 17:09:56 -04:00
greenart7c3
99b7a8005a Merge branch 'main' into main 2024-07-19 14:13:36 -03:00
greenart7c3
3f3367258f return true if at least one relay replied with success 2024-07-19 13:29:09 -03:00
greenart7c3
5cf8b5aa12 change proguard to see if now the client.listener works 2024-07-19 12:49:04 -03:00
greenart7c3
eba6d03b53 check if listeners work now 2024-07-19 12:17:26 -03:00
greenart7c3
de85239046 check if listeners work now 2024-07-19 11:56:30 -03:00
greenart7c3
cbd8acbd01 check eose too 2024-07-19 11:14:31 -03:00
greenart7c3
19c9bfa819 Merge remote-tracking branch 'origin/main' 2024-07-19 11:14:18 -03:00
greenart7c3
80c369a6ae change default timeout to 15 seconds 2024-07-19 10:56:35 -03:00
greenart7c3
d0190c87a2 add more logs, add optional listener, fix await call 2024-07-19 10:56:35 -03:00
greenart7c3
62a2cba18b fix lint 2024-07-19 10:56:35 -03:00
greenart7c3
59b3ff1b94 Added sendAndWaitForResponse to Client.kt 2024-07-19 10:56:35 -03:00
greenart7c3
35423e1bb6 change default timeout to 15 seconds 2024-07-19 10:52:36 -03:00
greenart7c3
e125690000 add more logs, add optional listener, fix await call 2024-07-19 10:50:59 -03:00
greenart7c3
04bf9c07ac fix lint 2024-07-19 08:05:04 -03:00
greenart7c3
ac58f99560 Added sendAndWaitForResponse to Client.kt 2024-07-19 07:58:12 -03:00
Believethehype
df328ff139 Store additional File Metadata in Gallery Entries 2024-07-18 17:53:37 +02:00
673 changed files with 41922 additions and 19003 deletions

View File

@@ -24,9 +24,12 @@ 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.
The size of the bounty is proportional to how much this matters to you. If no bounty is offered,
not even a small one, this bug will not be worked on because it doesn't matter to you. We prioritize
bug fixing to issues that have bounties, even small ones.

View File

@@ -7,14 +7,10 @@ assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Bounty (in Bitcoin sats) offered for the implementation**
Incentivize developers to work on your feature. Describe clear milestones to claim payment.
**Additional context**
Add any other context, video, or screenshots about the feature request here.
The size of the bounty is proportional to how much this matters to you. If no bounty is offered,
not even a small one, this feature will not be coded because it doesn't actually matter to you.
We prioritize feature development by its bounty size compared to how much work is required to get it done.

View File

@@ -42,15 +42,30 @@ jobs:
- name: Build APK (gradle)
run: ./gradlew assembleDebug --no-daemon
- name: Upload APK
uses: actions/upload-artifact@v2
- name: Upload Play APK
uses: actions/upload-artifact@v4
with:
name: Debug APK
path: amethyst/build/outputs/apk/debug/app-debug.apk
name: Play Debug APK
path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk
- name: Upload FDroid APK
uses: actions/upload-artifact@v4
with:
name: FDroid Debug APK
path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-debug.apk
- name: Upload Compose Reports
uses: actions/upload-artifact@v4
with:
name: Compose Reports
path: amethyst/build/compose_compiler
- name: Tests
run: ./gradlew test --no-daemon
- name: Upload Test Results
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
with:
name: Build Reports
name: Test Reports
path: amethyst/build/reports

1
.gitignore vendored
View File

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

View File

@@ -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" />
@@ -9,6 +37,10 @@
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />

2
.idea/kotlinc.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.0.0" />
<option name="version" value="2.1.0" />
</component>
</project>

View File

@@ -20,6 +20,9 @@ Join the social network you control.
## Download and Install
[<img src="./docs/design/zapstore.svg"
alt="Get it on Zap Store"
height="70">](https://github.com/zapstore/zapstore/releases)
[<img src="./docs/design/obtainium.png"
alt="Get it on Obtaininum"
height="70">](https://github.com/ImranR98/Obtainium)
@@ -105,7 +108,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Video Events (NIP-71)
- [x] Moderated Communities (NIP-72)
- [ ] Zap Goals (NIP-75)
- [ ] Arbitrary Custom App Data (NIP-78)
- [x] Arbitrary Custom App Data (NIP-78)
- [x] Highlights (NIP-84)
- [x] Notify Request (NIP-88/Draft)
- [x] Recommended Application Handlers (NIP-89)

View File

@@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.jetbrainsKotlinAndroid)
alias(libs.plugins.googleServices)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
}
android {
@@ -15,9 +16,9 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk libs.versions.android.minSdk.get().toInteger()
targetSdk libs.versions.android.targetSdk.get().toInteger()
versionCode 389
versionName "0.89.3"
buildConfigField "String", "RELEASE_NOTES_ID", "\"1130badb252bdf62aaf93cb65bfa2bb09e7600c7d6764894f2954555b3b73018\""
versionCode 417
versionName "0.94.2"
buildConfigField "String", "RELEASE_NOTES_ID", "\"fd42b23b9ef792059b1c1a89555443abbb11578f4b3c8430b452559eec7325f3\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -163,6 +164,7 @@ kotlin {
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
dependencies {
@@ -228,9 +230,8 @@ dependencies {
implementation libs.coil.gif
// view svgs
implementation libs.coil.svg
// create blurhash
implementation libs.trbl.blurhash
// enables network for coil
implementation libs.coil.okhttp
// Permission to upload pictures:
implementation libs.accompanist.permissions
@@ -251,6 +252,7 @@ dependencies {
// Language picker and Theme chooser
implementation libs.androidx.appcompat
implementation libs.androidx.window.core.android
// Local model for language identification
playImplementation libs.google.mlkit.language.id
@@ -282,8 +284,15 @@ dependencies {
// Image compression lib
implementation libs.zelory.video.compressor
// 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
@@ -293,5 +302,11 @@ dependencies {
debugImplementation platform(libs.androidx.compose.bom)
debugImplementation libs.androidx.ui.tooling
debugImplementation libs.androidx.ui.test.manifest
implementation libs.androidx.camera.core
implementation libs.androidx.camera.camera2
implementation libs.androidx.camera.lifecycle
implementation libs.androidx.camera.view
implementation libs.androidx.camera.extensions
}

View File

@@ -0,0 +1,102 @@
/**
* 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 androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.CashuProcessor
import com.vitorpamplona.amethyst.service.CashuToken
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.runBlocking
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CashuBTest {
val cashuTokenA = "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9"
val cashuTokenB1 = "cashuBo2F0gqJhaUgA_9SLj17PgGFwgaNhYQFhc3hAYWNjMTI0MzVlN2I4NDg0YzNjZjE4NTAxNDkyMThhZjkwZjcxNmE1MmJmNGE1ZWQzNDdlNDhlY2MxM2Y3NzM4OGFjWCECRFODGd5IXVW-07KaZCvuWHk3WrnnpiDhHki6SCQh88-iYWlIAK0mjE0fWCZhcIKjYWECYXN4QDEzMjNkM2Q0NzA3YTU4YWQyZTIzYWRhNGU5ZjFmNDlmNWE1YjRhYzdiNzA4ZWIwZDYxZjczOGY0ODMwN2U4ZWVhY1ghAjRWqhENhLSsdHrr2Cw7AFrKUL9Ffr1XN6RBT6w659lNo2FhAWFzeEA1NmJjYmNiYjdjYzY0MDZiM2ZhNWQ1N2QyMTc0ZjRlZmY4YjQ0MDJiMTc2OTI2ZDNhNTdkM2MzZGNiYjU5ZDU3YWNYIQJzEpxXGeWZN5qXSmJjY8MzxWyvwObQGr5G1YCCgHicY2FtdWh0dHA6Ly9sb2NhbGhvc3Q6MzMzOGF1Y3NhdA"
val cashuTokenB2 = "cashuBpGFkb01pbmliaXRzIHJ1bGVzIWFteEpodHRwOi8vbGJ1dGxoNWxmZ2dxNXI3eHBpd2hyYWpkbDdzeHB1cGdhZ2F6eGw2NXc0YzVjZzcyd3RvZmFzYWQub25pb246MzMzOGF0gaJhaUgAm7I9OpEuTmFwg6NhYRhAYWNYIQPfWR0mG80XbGnj6DO8q1NIyjHSGGIEkoWTA6H16HTpx2FzeEA3YThkY2Y5YjNlOGEyNDdjZTMzOWU3MzY5ZTliNGExOWYzMWVhY2I2OWQ4YjBjNjVkYWFlYjcyZDFhY2I5YWQzo2FhGCBhY1ghA55SwCFBc46dwnjbkb87Mzo30T2EE9Ws_nemuFneDegGYXN4QDlkODFjMWEyNjE2ODUzYWQ4MDQ5Y2JjZDFjN2MyNDdhZGQ4M2IzNzM4Mjg2MjBiYWMyZmQ3ZjNlNWE1OGFjZWKjYWEEYWNYIQM-yAQQTR2t6pIAmfmGM8Wxy7ajKVLOaUg7TrV8o-EdVWFzeEBmNmViMTI4ZmJlMDM3MTEzZTkzZjM3NjllYTYwMTk1NmY1N2NkZWNhNTYwOGY0NWUzMDhhZDU0ZmQ4YTQxNWVhYXVjc2F0"
@Test()
fun parseCashuA() {
runBlocking {
val parsed = (CashuProcessor().parse(cashuTokenA) as GenericLoadable.Loaded<List<CashuToken>>).loaded[0]
assertEquals(cashuTokenA, parsed.token)
assertEquals("https://8333.space:3338", parsed.mint)
assertEquals(10, parsed.totalAmount)
assertEquals(2, parsed.proofs[0].amount)
assertEquals("407915bc212be61a77e3e6d2aeb4c727980bda51cd06a6afc29e2861768a7837", parsed.proofs[0].secret)
assertEquals("009a1f293253e41e", parsed.proofs[0].id)
assertEquals("02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea", parsed.proofs[0].C)
assertEquals(8, parsed.proofs[1].amount)
assertEquals("fe15109314e61d7756b0f8ee0f23a624acaa3f4e042f61433c728c7057b931be", parsed.proofs[1].secret)
assertEquals("009a1f293253e41e", parsed.proofs[1].id)
assertEquals("029e8e5050b890a7d6c0968db16bc1d5d5fa040ea1de284f6ec69d61299f671059", parsed.proofs[1].C)
}
}
@Test()
fun parseCashuB() =
runBlocking {
val parsed = (CashuProcessor().parse(cashuTokenB1) as GenericLoadable.Loaded<List<CashuToken>>).loaded
assertEquals(cashuTokenB1, parsed[0].token)
assertEquals("http://localhost:3338", parsed[0].mint)
assertEquals(1, parsed[0].totalAmount)
assertEquals(1, parsed[0].proofs[0].amount)
assertEquals("acc12435e7b8484c3cf1850149218af90f716a52bf4a5ed347e48ecc13f77388", parsed[0].proofs[0].secret)
assertEquals("00ffd48b8f5ecf80", parsed[0].proofs[0].id)
assertEquals("0244538319de485d55bed3b29a642bee5879375ab9e7a620e11e48ba482421f3cf", parsed[0].proofs[0].C)
assertEquals(3, parsed[1].totalAmount)
assertEquals(2, parsed[1].proofs[0].amount)
assertEquals("1323d3d4707a58ad2e23ada4e9f1f49f5a5b4ac7b708eb0d61f738f48307e8ee", parsed[1].proofs[0].secret)
assertEquals("00ad268c4d1f5826", parsed[1].proofs[0].id)
assertEquals("023456aa110d84b4ac747aebd82c3b005aca50bf457ebd5737a4414fac3ae7d94d", parsed[1].proofs[0].C)
assertEquals(1, parsed[1].proofs[1].amount)
assertEquals("56bcbcbb7cc6406b3fa5d57d2174f4eff8b4402b176926d3a57d3c3dcbb59d57", parsed[1].proofs[1].secret)
assertEquals("00ad268c4d1f5826", parsed[1].proofs[1].id)
assertEquals("0273129c5719e599379a974a626363c333c56cafc0e6d01abe46d5808280789c63", parsed[1].proofs[1].C)
}
@Test()
fun parseCashuB2() =
runBlocking {
val parsed = (CashuProcessor().parse(cashuTokenB2) as GenericLoadable.Loaded<List<CashuToken>>).loaded
assertEquals(cashuTokenB2, parsed[0].token)
assertEquals("http://lbutlh5lfggq5r7xpiwhrajdl7sxpupgagazxl65w4c5cg72wtofasad.onion:3338", parsed[0].mint)
assertEquals(100, parsed[0].totalAmount)
assertEquals(64, parsed[0].proofs[0].amount)
assertEquals("7a8dcf9b3e8a247ce339e7369e9b4a19f31eacb69d8b0c65daaeb72d1acb9ad3", parsed[0].proofs[0].secret)
assertEquals("009bb23d3a912e4e", parsed[0].proofs[0].id)
assertEquals("03df591d261bcd176c69e3e833bcab5348ca31d218620492859303a1f5e874e9c7", parsed[0].proofs[0].C)
assertEquals(32, parsed[0].proofs[1].amount)
assertEquals("9d81c1a2616853ad8049cbcd1c7c247add83b373828620bac2fd7f3e5a58aceb", parsed[0].proofs[1].secret)
assertEquals("009bb23d3a912e4e", parsed[0].proofs[1].id)
assertEquals("039e52c02141738e9dc278db91bf3b333a37d13d8413d5acfe77a6b859de0de806", parsed[0].proofs[1].C)
}
}

View File

@@ -23,15 +23,25 @@ package com.vitorpamplona.amethyst
import android.graphics.Bitmap
import android.graphics.Color
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.Nip96Retriever
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.service.uploads.ImageDownloader
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.service.uploads.nip96.ServerInfoRetriever
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.toHexKey
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.fail
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import org.junit.Assert
import org.junit.Ignore
@@ -42,82 +52,125 @@ import kotlin.random.Random
@RunWith(AndroidJUnit4::class)
class ImageUploadTesting {
private suspend fun testBase(server: Nip96MediaServers.ServerName) {
val serverInfo =
Nip96Retriever()
.loadInfo(
server.baseUrl,
)
val account =
Account(
AccountSettings(KeyPair()),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
)
private suspend fun getBitmap(): ByteArray {
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888)
for (x in 0 until bitmap.width) {
for (y in 0 until bitmap.height) {
bitmap.setPixel(x, y, Color.rgb(Random.nextInt(), Random.nextInt(), Random.nextInt()))
}
}
val baos = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
val bytes = baos.toByteArray()
val inputStream = bytes.inputStream()
return baos.toByteArray()
}
val account = Account(KeyPair())
private suspend fun testBase(server: ServerName) {
if (server.type == ServerType.NIP96) {
testNip96(server)
} else {
testBlossom(server)
}
}
private suspend fun testBlossom(server: ServerName) {
val paylod = getBitmap()
val initialHash = CryptoUtils.sha256(paylod).toHexKey()
val inputStream = paylod.inputStream()
val result =
Nip96Uploader(account)
BlossomUploader()
.uploadImage(
inputStream,
bytes.size.toLong(),
"image/png",
inputStream = inputStream,
hash = initialHash,
length = paylod.size,
baseFileName = "filename.png",
contentType = "image/png",
alt = null,
sensitiveContent = null,
serverInfo,
onProgress = {},
serverBaseUrl = server.baseUrl,
forceProxy = { false },
httpAuth = account::createBlossomUploadAuth,
context = InstrumentationRegistry.getInstrumentation().targetContext,
)
val url = result.tags!!.first { it[0] == "url" }.get(1)
val size =
result.tags!!
.firstOrNull { it[0] == "size" }
?.get(1)
?.ifBlank { null }
val dim =
result.tags!!
.firstOrNull { it[0] == "dim" }
?.get(1)
?.ifBlank { null }
val hash =
result.tags!!
.firstOrNull { it[0] == "x" }
?.get(1)
?.ifBlank { null }
val contentType = result.tags!!.first { it[0] == "m" }.get(1)
val ox = result.tags!!.first { it[0] == "ox" }.get(1)
Assert.assertTrue("${server.name}: Invalid result url", url.startsWith("http"))
assertEquals("image/png", result.type)
assertEquals(paylod.size.toLong(), result.size)
assertEquals(initialHash, result.sha256)
assertEquals("${server.baseUrl}/$initialHash", result.url?.removeSuffix(".png"))
val imageData: ByteArray =
ImageDownloader().waitAndGetImage(url)
ImageDownloader().waitAndGetImage(result.url!!, false)
?: run {
fail("${server.name}: Should not be null")
return
}
FileHeader.prepare(
imageData,
"image/png",
null,
onReady = {
val downloadedHash = CryptoUtils.sha256(imageData).toHexKey()
assertEquals(initialHash, downloadedHash)
}
private suspend fun testNip96(server: ServerName) {
val serverInfo =
ServerInfoRetriever()
.loadInfo(
server.baseUrl,
false,
)
val paylod = getBitmap()
val inputStream = paylod.inputStream()
val result =
Nip96Uploader()
.uploadImage(
inputStream = inputStream,
length = paylod.size.toLong(),
contentType = "image/png",
alt = null,
sensitiveContent = null,
server = serverInfo,
forceProxy = { false },
onProgress = {},
httpAuth = account::createHTTPAuthorization,
context = InstrumentationRegistry.getInstrumentation().targetContext,
)
val url = result.url!!
val size = result.size
val dim = result.dimension
val hash = result.sha256
Assert.assertTrue("${server.name}: Invalid result url", url.startsWith("http"))
val imageData: ByteArray =
ImageDownloader().waitAndGetImage(url, false)
?: run {
fail("${server.name}: Should not be null")
return
}
val prepared =
FileHeader.prepare(
imageData,
"image/png",
null,
)
prepared.fold(
onSuccess = {
if (dim != null) {
// assertEquals("${server.name}: Invalid dimensions", it.dim, dim)
assertEquals("${server.name}: Invalid dimensions", it.dim.toString(), dim.toString())
}
if (size != null) {
// assertEquals("${server.name}: Invalid size", it.size.toString(), size)
}
if (hash != null) {
assertEquals("${server.name}: Invalid hash", it.hash, hash)
assertEquals("${server.name}: Invalid size", it.size.toString(), size)
}
},
onError = { fail("${server.name}: It should not fail") },
onFailure = { fail("${server.name}: It should not fail") },
)
// delay(1000)
@@ -128,60 +181,88 @@ class ImageUploadTesting {
@Test
fun runTestOnDefaultServers() =
runBlocking {
Nip96MediaServers.DEFAULT.forEach {
testBase(it)
DEFAULT_MEDIA_SERVERS.forEach {
// skip paid servers and primal server is buggy.
if (!it.name.contains("Paid") && !it.name.contains("Primal")) {
testBase(it)
}
}
}
@Test()
fun testNostrCheck() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me"))
testBase(ServerName("nostrcheck.me", "https://nostrcheck.me", ServerType.NIP96))
}
@Test()
@Ignore("Not Working anymore")
fun testNostrage() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com"))
testBase(ServerName("nostrage", "https://nostrage.com", ServerType.NIP96))
}
@Test()
@Ignore("Not Working anymore")
fun testSove() =
runBlocking {
testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent"))
testBase(ServerName("sove", "https://sove.rent", ServerType.NIP96))
}
@Test()
fun testNostrBuild() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build"))
testBase(ServerName("nostr.build", "https://nostr.build", ServerType.NIP96))
}
@Test()
@Ignore("Not Working anymore")
fun testSovbit() =
runBlocking {
testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host"))
testBase(ServerName("sovbit", "https://files.sovbit.host", ServerType.NIP96))
}
@Test()
fun testVoidCat() =
runBlocking {
testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat"))
testBase(ServerName("void.cat", "https://void.cat", ServerType.NIP96))
}
@Test()
@Ignore("Returns invalid image size")
fun testNostrPic() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostpic.com", "https://nostpic.com"))
testBase(ServerName("nostpic.com", "https://nostpic.com", ServerType.NIP96))
}
@Test(expected = RuntimeException::class)
fun testSprovoostNl() =
runBlocking {
testBase(ServerName("sprovoost.nl", "https://img.sprovoost.nl/", ServerType.NIP96))
}
@Test()
@Ignore("Not Working anymore")
fun testNostrOnch() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostr.onch.services", "https://nostr.onch.services"))
testBase(ServerName("nostr.onch.services", "https://nostr.onch.services", ServerType.NIP96))
}
@Ignore("Changes sha256")
fun testPrimalBlossom() =
runBlocking {
testBase(ServerName("primal.net", "https://blossom.primal.net", ServerType.Blossom))
}
@Test()
fun testNostrCheckBlossom() =
runBlocking {
testBase(ServerName("nostrcheck", "https://cdn.nostrcheck.me", ServerType.Blossom))
}
@Ignore("Requires Payment")
fun testSatelliteBlossom() =
runBlocking {
testBase(ServerName("satellite", "https://cdn.satellite.earth", ServerType.Blossom))
}
}

View File

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

View File

@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
import com.vitorpamplona.quartz.crypto.KeyPair
@@ -30,7 +31,9 @@ import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.events.Event
import junit.framework.TestCase
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.junit.Test
@@ -133,7 +136,7 @@ class ThreadAssemblerTest {
null,
)
val account = Account(KeyPair())
val account = Account(AccountSettings(KeyPair()), scope = CoroutineScope(Dispatchers.IO + SupervisorJob()))
withContext(Dispatchers.Main) {
val user = account.userProfile().live()
}

View File

@@ -0,0 +1,62 @@
/**
* 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 com.vitorpamplona.amethyst.ui.navigation.findParameterValue
import junit.framework.TestCase.assertEquals
import org.junit.Test
import java.net.URI
class URIParserTest {
@Test
fun testNEventWithAccount() {
val test = "nevent1qqsp9wg5r3vkuv5al0459h3h44yxh6r0y7chgvu3pkdxhcj99t3msrgpzdmhxue69uhhwmm59e6hg7r09ehkuef0qgsdhcrqt2w8x9et446j8ge8kgmd2h4ykc6wsrnc4yqnmdu3lr74ktqrqsqqqqqp578kku?account=npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"
val testUri = URI(test)
// assertEquals("nostr", testUri.scheme)
assertEquals(null, testUri.authority)
assertEquals(null, testUri.host)
assertEquals(-1, testUri.port)
assertEquals(null, testUri.userInfo)
assertEquals("nevent1qqsp9wg5r3vkuv5al0459h3h44yxh6r0y7chgvu3pkdxhcj99t3msrgpzdmhxue69uhhwmm59e6hg7r09ehkuef0qgsdhcrqt2w8x9et446j8ge8kgmd2h4ykc6wsrnc4yqnmdu3lr74ktqrqsqqqqqp578kku", testUri.path)
assertEquals(null, testUri.fragment)
assertEquals("account=npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", testUri.rawQuery)
assertEquals("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", testUri.findParameterValue("account"))
}
@Test
fun testNotificationsWithAccount() {
val test = "notifications?account=npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"
val testUri = URI(test)
// assertEquals("nostr", testUri.scheme)
assertEquals(null, testUri.authority)
assertEquals(null, testUri.host)
assertEquals(-1, testUri.port)
assertEquals(null, testUri.userInfo)
assertEquals("notifications", testUri.path)
assertEquals(null, testUri.fragment)
assertEquals("account=npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", testUri.rawQuery)
assertEquals("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", testUri.findParameterValue("account"))
}
}

View File

@@ -27,6 +27,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CancellationException
@RunWith(AndroidJUnit4::class)
class TranslationsTest {
@@ -124,7 +125,10 @@ class TranslationsTest {
"Have you seen this: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn I think I have to pay",
"pt",
)
}
@Test(expected = CancellationException::class)
fun testTranslationLnInvoice2() {
assertTranslateContains(
"lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
"Test lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
@@ -132,7 +136,7 @@ class TranslationsTest {
)
}
@Test
@Test(expected = CancellationException::class)
fun testNostrEvents() {
assertTranslateContains(
"nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
@@ -162,4 +166,15 @@ class TranslationsTest {
"pt",
)
}
@Test
fun testHttp() {
val text = "https://m.primal.net/MdDd.png \nRunning... \uD83D\uDE01 nostr:npub126ntw5mnermmj0znhjhgdk8lh2af72sm8qfzq48umdlnhaj9kuns3le9ll nostr:npub1getal6ykt05fsz5nqu4uld09nfj3y3qxmv8crys4aeut53unfvlqr80nfm"
assertTranslateContains(
"https://m.primal.net/MdDd.png",
text,
"pt",
)
}
}

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".Amethyst">

View File

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

View File

@@ -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
@@ -33,10 +32,7 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrC
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.GiftWrapEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import org.unifiedpush.android.connector.MessagingReceiver
@@ -46,7 +42,7 @@ class PushMessageReceiver : MessagingReceiver() {
}
private val appContext = Amethyst.instance.applicationContext
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val scope = Amethyst.instance.applicationIOScope
private val eventCache = LruCache<String, String>(100)
private val pushHandler = PushDistributorHandler
@@ -55,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}")
@@ -86,32 +84,25 @@ 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,
) {
Log.d(TAG, "Registration failed for Instance: $instance")
scope.cancel()
pushHandler.forceRemoveDistributor(context)
}
@@ -119,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)

View File

@@ -29,11 +29,14 @@ object PushNotificationUtils {
private val pushHandler = PushDistributorHandler
suspend fun init(accounts: List<AccountInfo>) {
if (hasInit || pushHandler.savedDistributorExists()) {
if (hasInit) {
return
}
try {
if (pushHandler.savedDistributorExists()) {
val currentDistributor = PushDistributorHandler.getSavedDistributor()
PushDistributorHandler.saveDistributor(currentDistributor)
RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint())
}
} catch (e: Exception) {

View File

@@ -55,10 +55,10 @@ import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CheckifItNeedsToRequestNotificationPermission
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
@@ -67,7 +67,7 @@ import kotlinx.collections.immutable.toImmutableList
@Composable
fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) {
val notificationPermissionState =
CheckifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel)
checkifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel)
if (notificationPermissionState.status.isGranted) {
if (!sharedPreferencesViewModel.sharedPrefs.dontShowPushNotificationSelector) {
@@ -150,9 +150,6 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
)
}
}
} else {
val currentDistributor = PushDistributorHandler.getSavedDistributor()
PushDistributorHandler.saveDistributor(currentDistributor)
}
}
}

View File

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

View File

@@ -16,12 +16,11 @@
<!-- To connect with relays -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- To Upload media (newer devices) -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<!-- To take pictures -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- To Upload media (old devices) -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<!-- To read QRs that contain nostr:<NIP19> -->
<uses-permission android:name="android.permission.CAMERA" />
@@ -100,9 +99,23 @@
<data android:scheme="nostr+walletconnect" />
</intent-filter>
<meta-data
android:name="android.app.lib_name"
android:value="" />
<intent-filter android:label="New Post">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:label="New Post">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter android:label="New Post">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<activity
@@ -121,6 +134,25 @@
</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>
<receiver
android:name=".service.notifications.PokeyReceiver"
android:exported="true"
>
<intent-filter>
<action android:name="com.shared.NOSTR" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,182 @@
/**
* 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 androidx.compose.material3.adaptive
import androidx.compose.runtime.Immutable
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.toComposeRect
import androidx.window.layout.FoldingFeature
/**
* Calculates the [Posture] for a given list of [FoldingFeature]s. This methods converts framework
* folding info into the Material-opinionated posture info.
*/
fun calculatePosture(foldingFeatures: List<FoldingFeature>): Posture {
var isTableTop = false
val hingeList = mutableListOf<HingeInfo>()
@Suppress("ListIterator")
foldingFeatures.forEach {
if (it.orientation == FoldingFeature.Orientation.HORIZONTAL &&
it.state == FoldingFeature.State.HALF_OPENED
) {
isTableTop = true
}
hingeList.add(
HingeInfo(
bounds = it.bounds.toComposeRect(),
isFlat = it.state == FoldingFeature.State.FLAT,
isVertical = it.orientation == FoldingFeature.Orientation.VERTICAL,
isSeparating = it.isSeparating,
isOccluding = it.occlusionType == FoldingFeature.OcclusionType.FULL,
),
)
}
return Posture(isTableTop, hingeList)
}
/**
* Posture info that can help make layout adaptation decisions. For example when
* [Posture.separatingVerticalHingeBounds] is not empty, the layout may want to avoid putting any
* content over those hinge area. We suggest to use [calculatePosture] to retrieve instances of this
* class in applications, unless you have a strong need of customization that cannot be fulfilled by
* the default implementation.
*
* Note that the hinge bounds will be represent as [Rect] with window coordinates, instead of layout
* coordinate.
*
* @constructor create an instance of [Posture]
* @property isTabletop `true` if the current window is considered as in the table top mode, i.e.
* there is one half-opened horizontal hinge in the middle of the current window. When
* this is `true` it usually means it's hard for users to interact with the window area
* around the hinge and developers may consider separating the layout along the hinge and
* show software keyboard or other controls in the bottom half of the window.
* @property hingeList a list of all hinges that are relevant to the posture.
*/
@Immutable
class Posture(
val isTabletop: Boolean = false,
val hingeList: List<HingeInfo> = emptyList(),
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Posture) return false
if (isTabletop != other.isTabletop) return false
if (hingeList != other.hingeList) return false
return true
}
override fun hashCode(): Int {
var result = isTabletop.hashCode()
result = 31 * result + hingeList.hashCode()
return result
}
override fun toString(): String {
@Suppress("ListIterator")
return "Posture(isTabletop=$isTabletop, " +
"hinges=[${hingeList.joinToString(", ")}])"
}
}
/**
* Returns the list of vertical hinge bounds that are separating.
*/
val Posture.separatingVerticalHingeBounds get() = hingeList.getBounds { isVertical && isSeparating }
/**
* Returns the list of vertical hinge bounds that are occluding.
*/
val Posture.occludingVerticalHingeBounds get() = hingeList.getBounds { isVertical && isOccluding }
/**
* Returns the list of all vertical hinge bounds.
*/
val Posture.allVerticalHingeBounds get() = hingeList.getBounds { isVertical }
/**
* Returns the list of horizontal hinge bounds that are separating.
*/
val Posture.separatingHorizontalHingeBounds
get() = hingeList.getBounds { !isVertical && isSeparating }
/**
* Returns the list of horizontal hinge bounds that are occluding.
*/
val Posture.occludingHorizontalHingeBounds
get() = hingeList.getBounds { !isVertical && isOccluding }
/**
* Returns the list of all horizontal hinge bounds.
*/
val Posture.allHorizontalHingeBounds
get() = hingeList.getBounds { !isVertical }
/**
* A class that contains the info of a hinge relevant to a [Posture].
*
* @param bounds the bounds of the hinge in the relevant viewport.
* @param isFlat `true` if the hinge is fully open and the relevant window space presented to the
* user is flat.
* @param isVertical `true` if the hinge is a vertical one, i.e., it separates the viewport into
* left and right; `false` if the hinge is horizontal, i.e., it separates the viewport
* into top and bottom.
* @param isSeparating `true` if the hinge creates two logical display areas.
* @param isOccluding `true` if the hinge conceals part of the display.
*/
@Immutable
class HingeInfo(
val bounds: Rect,
val isFlat: Boolean,
val isVertical: Boolean,
val isSeparating: Boolean,
val isOccluding: Boolean,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is HingeInfo) return false
if (bounds != other.bounds) return false
if (isFlat != other.isFlat) return false
if (isVertical != other.isVertical) return false
if (isSeparating != other.isSeparating) return false
if (isOccluding != other.isOccluding) return false
return true
}
override fun hashCode(): Int {
var result = bounds.hashCode()
result = 31 * result + isFlat.hashCode()
result = 31 * result + isVertical.hashCode()
result = 31 * result + isSeparating.hashCode()
result = 31 * result + isOccluding.hashCode()
return result
}
override fun toString(): String =
"HingeInfo(bounds=$bounds, " +
"isFlat=$isFlat, " +
"isVertical=$isVertical, " +
"isSeparating=$isSeparating, " +
"isOccluding=$isOccluding)"
}
private inline fun List<HingeInfo>.getBounds(predicate: HingeInfo.() -> Boolean): List<Rect> =
@Suppress("ListIterator")
mapNotNull { if (it.predicate()) it.bounds else null }

View File

@@ -0,0 +1,122 @@
/**
* 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 androidx.compose.material3.adaptive
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.toSize
import androidx.window.core.layout.WindowSizeClass
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import androidx.window.layout.WindowMetricsCalculator
import kotlinx.coroutines.flow.map
@Composable
fun currentWindowAdaptiveInfo(): WindowAdaptiveInfo {
val windowSize =
with(LocalDensity.current) {
currentWindowSize().toSize().toDpSize()
}
return WindowAdaptiveInfo(
WindowSizeClass.compute(windowSize.width.value, windowSize.height.value),
calculatePosture(collectFoldingFeaturesAsState().value),
)
}
/**
* Returns and automatically update the current window size from [WindowMetricsCalculator].
*
* @return an [IntSize] that represents the current window size.
*/
@Composable
fun currentWindowSize(): IntSize {
// Observe view configuration changes and recalculate the size class on each change. We can't
// use Activity#onConfigurationChanged as this will sometimes fail to be called on different
// API levels, hence why this function needs to be @Composable so we can observe the
// ComposeView's configuration changes.
LocalConfiguration.current
val windowBounds =
WindowMetricsCalculator
.getOrCreate()
.computeCurrentWindowMetrics(LocalContext.current)
.bounds
return IntSize(windowBounds.width(), windowBounds.height())
}
/**
* Collects the current window folding features from [WindowInfoTracker] in to a [State].
*
* @return a [State] of a [FoldingFeature] list.
*/
@Composable
fun collectFoldingFeaturesAsState(): State<List<FoldingFeature>> {
val context = LocalContext.current
return remember(context) {
if (context is Activity) {
// TODO(b/284347941) remove the instance check after the test bug is fixed.
WindowInfoTracker
.getOrCreate(context)
.windowLayoutInfo(context)
} else {
WindowInfoTracker
.getOrCreate(context)
.windowLayoutInfo(context)
}.map { it.displayFeatures.filterIsInstance<FoldingFeature>() }
}.collectAsState(emptyList())
}
/**
* This class collects window info that affects adaptation decisions. An adaptive layout is supposed
* to use the info from this class to decide how the layout is supposed to be adapted.
*
* @constructor create an instance of [WindowAdaptiveInfo]
* @param windowSizeClass [WindowSizeClass] of the current window.
* @param windowPosture [Posture] of the current window.
*/
@Immutable
class WindowAdaptiveInfo(
val windowSizeClass: WindowSizeClass,
val windowPosture: Posture,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is WindowAdaptiveInfo) return false
if (windowSizeClass != other.windowSizeClass) return false
if (windowPosture != other.windowPosture) return false
return true
}
override fun hashCode(): Int {
var result = windowSizeClass.hashCode()
result = 31 * result + windowPosture.hashCode()
return result
}
override fun toString(): String = "WindowAdaptiveInfo(windowSizeClass=$windowSizeClass, windowPosture=$windowPosture)"
}

View File

@@ -21,42 +21,63 @@
package com.vitorpamplona.amethyst
import android.app.Application
import android.content.ContentResolver
import android.content.Context
import android.content.IntentFilter
import android.os.Build
import android.os.Looper
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import coil.ImageLoader
import coil.disk.DiskCache
import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
import coil3.ImageLoader
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.service.LocationState
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.playback.VideoCache
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.ots.OpenTimestamps
import com.vitorpamplona.ammolite.relays.NostrClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okio.Path.Companion.toOkioPath
import java.io.File
import kotlin.time.measureTimedValue
class Amethyst : Application() {
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val client: NostrClient = NostrClient(OkHttpWebSocket.Builder())
// Service Manager is only active when the activity is active.
val serviceManager = ServiceManager(client, applicationIOScope)
val locationManager = LocationState(this, applicationIOScope)
val pokeyReceiver = PokeyReceiver()
override fun onTerminate() {
super.onTerminate()
unregisterReceiver(pokeyReceiver)
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
}
@@ -64,18 +85,27 @@ class Amethyst : Application() {
val coilCache: DiskCache by lazy {
DiskCache
.Builder()
.directory(applicationContext.safeCacheDir.resolve("image_cache"))
.directory(safeCacheDir.resolve("image_cache").toOkioPath())
.maxSizePercent(0.2)
.maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB
.maximumMaxSizeBytes(1024 * 1024 * 1024) // 1GB
.build()
}
val memoryCache: MemoryCache by lazy {
MemoryCache
.Builder()
.maxSizePercent(this)
.build()
}
fun contentResolverFn(): ContentResolver = contentResolver
override fun onCreate() {
super.onCreate()
instance = this
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(), OkHttpCalendarBuilder())
HttpClientManager.setDefaultUserAgent("Amethyst/${BuildConfig.VERSION_NAME}")
if (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark") {
StrictMode.setThreadPolicy(
@@ -104,12 +134,44 @@ class Amethyst : Application() {
}
Log.d("Rendering Metrics", "VideoCache initialized in $elapsed")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(
pokeyReceiver,
IntentFilter(PokeyReceiver.POKEY_ACTION),
RECEIVER_EXPORTED,
)
} else {
registerReceiver(
pokeyReceiver,
IntentFilter(PokeyReceiver.POKEY_ACTION),
)
}
}
fun imageLoaderBuilder(): ImageLoader.Builder = ImageLoader.Builder(applicationContext).diskCache { coilCache }
fun imageLoaderBuilder(): ImageLoader.Builder =
ImageLoader
.Builder(this)
.diskCache { coilCache }
.memoryCache { memoryCache }
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(instance, npub)
/**
* Release memory when the UI becomes hidden or when system resources become low.
*
* @param level the memory-related event that was raised.
*/
@OptIn(DelicateCoroutinesApi::class)
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
println("Trim Memory $level")
GlobalScope.launch(Dispatchers.Default) {
println("Trim Memory Inside $level")
serviceManager.trimMemory()
}
}
companion object {
lateinit var instance: Amethyst
private set

View File

@@ -0,0 +1,176 @@
/**
* 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 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
fun debugState(context: Context) {
Amethyst.instance.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: " + Amethyst.instance.client.connectedRelays())
Log.d(
"STATE DUMP",
"Image Disk Cache ${(Amethyst.instance.coilCache.size) / (1024 * 1024)}/${(Amethyst.instance.coilCache.maxSize) / (1024 * 1024)} MB",
)
Log.d(
"STATE DUMP",
"Image Memory Cache ${(Amethyst.instance.memoryCache.size) / (1024 * 1024)}/${(Amethyst.instance.memoryCache.size) / (1024 * 1024)} MB",
)
Log.d(
"STATE DUMP",
"Notes: " +
LocalCache.notes.filter { _, it -> it.liveSet != null }.size +
" / " +
LocalCache.notes.filter { _, it -> it.flowSet != null }.size +
" / " +
LocalCache.notes.filter { _, it -> it.event != null }.size +
" / " +
LocalCache.notes.size(),
)
Log.d(
"STATE DUMP",
"Addressables: " +
LocalCache.addressables.filter { _, it -> it.liveSet != null }.size +
" / " +
LocalCache.addressables.filter { _, it -> it.flowSet != null }.size +
" / " +
LocalCache.addressables.filter { _, it -> it.event != null }.size +
" / " +
LocalCache.addressables.size(),
)
Log.d(
"STATE DUMP",
"Users: " +
LocalCache.users.filter { _, it -> it.liveSet != null }.size +
" / " +
LocalCache.users.filter { _, it -> it.flowSet != null }.size +
" / " +
LocalCache.users.filter { _, it -> it.latestMetadata != null }.size +
" / " +
LocalCache.users.size(),
)
Log.d(
"STATE DUMP",
"Deletion Events: " +
LocalCache.deletionIndex.size(),
)
Log.d(
"STATE DUMP",
"Observable Events: " +
LocalCache.observablesByKindAndETag.size +
" / " +
LocalCache.observablesByKindAndAuthor.size,
)
Log.d(
"STATE DUMP",
"Spam: " +
LocalCache.antiSpam.spamMessages.size() + " / " + LocalCache.antiSpam.recentMessages.size(),
)
Log.d(
"STATE DUMP",
"Memory used by Events: " +
LocalCache.notes.sumOfLong { _, note -> note.event?.countMemory() ?: 0L } / (1024 * 1024) +
" MB",
)
val qttNotes = LocalCache.notes.countByGroup { _, it -> it.event?.kind() }
val qttAddressables = LocalCache.addressables.countByGroup { _, it -> it.event?.kind() }
val bytesNotes =
LocalCache.notes
.sumByGroup(groupMap = { _, it -> it.event?.kind() }, sumOf = { _, it -> it.event?.countMemory() ?: 0L })
val bytesAddressables =
LocalCache.addressables
.sumByGroup(groupMap = { _, it -> it.event?.kind() }, sumOf = { _, it -> it.event?.countMemory() ?: 0L })
qttNotes.toList().sortedByDescending { bytesNotes.get(it.first) }.forEach { (kind, qtt) ->
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesNotes.get(kind)?.div((1024 * 1024))}MB ")
}
qttAddressables.toList().sortedByDescending { bytesNotes.get(it.first) }.forEach { (kind, qtt) ->
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesAddressables.get(kind)?.div((1024 * 1024))}MB ")
}
}

View File

@@ -26,16 +26,24 @@ import android.content.SharedPreferences
import android.util.Log
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountLanguagePreferencesInternal
import com.vitorpamplona.amethyst.model.AccountReactionPreferencesInternal
import com.vitorpamplona.amethyst.model.AccountSecurityPreferencesInternal
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AccountSyncedSettingsInternal
import com.vitorpamplona.amethyst.model.AccountZapPreferencesInternal
import com.vitorpamplona.amethyst.model.DefaultReactions
import com.vitorpamplona.amethyst.model.DefaultZapAmounts
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
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.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
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
@@ -43,13 +51,15 @@ import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AppSpecificDataEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.signers.ExternalSignerLauncher
import com.vitorpamplona.quartz.signers.NostrSignerExternal
import com.vitorpamplona.quartz.signers.NostrSignerInternal
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.events.SearchRelayListEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -70,6 +80,7 @@ data class AccountInfo(
val npub: String,
val hasPrivKey: Boolean,
val loggedInWithExternalSigner: Boolean,
val isTransient: Boolean,
)
private object PrefKeys {
@@ -91,12 +102,18 @@ private object PrefKeys {
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
const val LATEST_USER_METADATA = "latestUserMetadata"
const val LATEST_CONTACT_LIST = "latestContactList"
const val LATEST_DM_RELAY_LIST = "latestDMRelayList"
const val LATEST_NIP65_RELAY_LIST = "latestNIP65RelayList"
const val LATEST_SEARCH_RELAY_LIST = "latestSearchRelayList"
const val LATEST_MUTE_LIST = "latestMuteList"
const val LATEST_PRIVATE_HOME_RELAY_LIST = "latestPrivateHomeRelayList"
const val LATEST_APP_SPECIFIC_DATA = "latestAppSpecificData"
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"
@@ -116,68 +133,85 @@ object LocalPreferences {
private const val COMMA = ","
private var currentAccount: String? = null
private var savedAccounts: List<AccountInfo>? = null
private var cachedAccounts: MutableMap<String, Account?> = mutableMapOf()
private var savedAccounts: MutableStateFlow<List<AccountInfo>?> = MutableStateFlow(null)
private var cachedAccounts: MutableMap<String, AccountSettings?> = mutableMapOf()
suspend fun currentAccount(): String? {
if (currentAccount == null) {
currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
currentAccount =
withContext(Dispatchers.IO) {
encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
}
}
return currentAccount
}
private fun updateCurrentAccount(npub: String?) {
if (npub == null) {
private suspend fun updateCurrentAccount(info: AccountInfo?) {
if (info == null) {
currentAccount = null
encryptedPreferences().edit().clear().apply()
} else if (currentAccount != npub) {
currentAccount = npub
encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, npub) }.apply()
}
}
private fun savedAccounts(): List<AccountInfo> {
if (savedAccounts == null) {
with(encryptedPreferences()) {
val newSystemOfAccounts =
getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
Event.mapper.readValue<List<AccountInfo>>(it)
}
if (!newSystemOfAccounts.isNullOrEmpty()) {
savedAccounts = newSystemOfAccounts
} else {
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
val migrated =
oldAccounts.map { npub ->
AccountInfo(
npub,
encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false),
(encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "")
.isNotBlank(),
)
}
savedAccounts = migrated
edit().apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(savedAccounts)) }.apply()
withContext(Dispatchers.IO) {
encryptedPreferences().edit().clear().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()
}
}
}
return savedAccounts!!
}
private suspend fun savedAccounts(): List<AccountInfo> {
if (savedAccounts.value == null) {
withContext(Dispatchers.IO) {
with(encryptedPreferences()) {
val newSystemOfAccounts =
getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
Event.mapper.readValue<List<AccountInfo>>(it)
}
if (!newSystemOfAccounts.isNullOrEmpty()) {
savedAccounts.emit(newSystemOfAccounts)
} else {
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
val migrated =
oldAccounts.map { npub ->
AccountInfo(
npub,
encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false),
(encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank(),
false,
)
}
savedAccounts.emit(migrated)
edit().apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(savedAccounts.value)) }.apply()
}
}
}
}
// it's always not null when it gets here.
return savedAccounts.value!!
}
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()
}
}
@@ -189,35 +223,34 @@ object LocalPreferences {
updateSavedAccounts(accounts)
}
private suspend fun setCurrentAccount(account: Account) =
withContext(Dispatchers.IO) {
val npub = account.userProfile().pubkeyNpub()
val accInfo =
AccountInfo(
npub,
account.isWriteable(),
account.signer is NostrSignerExternal,
)
updateCurrentAccount(npub)
addAccount(accInfo)
}
private suspend fun setCurrentAccount(accountSettings: AccountSettings) {
val npub = accountSettings.keyPair.pubKey.toNpub()
val accInfo =
AccountInfo(
npub,
accountSettings.isWriteable(),
accountSettings.externalSignerPackageName != null,
accountSettings.transientAccount,
)
updateCurrentAccount(accInfo)
addAccount(accInfo)
}
suspend fun switchToAccount(accountInfo: AccountInfo) = withContext(Dispatchers.IO) { 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) {
val accounts = savedAccounts().filter { it.npub != accountInfo.npub }
updateSavedAccounts(accounts)
updateSavedAccounts(savedAccounts().filter { it.npub != accountInfo.npub })
}
/** Deletes the npub-specific shared preference file */
private fun deleteUserPreferenceFile(npub: String) {
checkNotInMainThread()
val prefsDir = File(prefsDirPath)
prefsDir.list()?.forEach {
if (it.contains(npub)) {
File(prefsDir, it).delete()
private suspend fun deleteUserPreferenceFile(npub: String) {
withContext(Dispatchers.IO) {
val prefsDir = File(prefsDirPath)
prefsDir.list()?.forEach {
if (it.contains(npub)) {
File(prefsDir, it).delete()
}
}
}
}
@@ -244,132 +277,174 @@ 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) {
val userPrefs = encryptedPreferences(accountInfo.npub)
userPrefs.edit().clear().commit()
encryptedPreferences(accountInfo.npub).edit().clear().commit()
removeAccount(accountInfo)
deleteUserPreferenceFile(accountInfo.npub)
if (savedAccounts().isEmpty()) {
updateCurrentAccount(null)
} else if (currentAccount() == accountInfo.npub) {
updateCurrentAccount(savedAccounts().elementAt(0).npub)
updateCurrentAccount(savedAccounts().elementAt(0))
}
}
}
suspend fun updatePrefsForLogin(account: Account) {
setCurrentAccount(account)
saveToEncryptedStorage(account)
suspend fun updatePrefsForLogin(accountSettings: AccountSettings) {
setCurrentAccount(accountSettings)
saveToEncryptedStorage(accountSettings)
}
fun allSavedAccounts(): List<AccountInfo> = savedAccounts()
suspend fun allSavedAccounts(): List<AccountInfo> = savedAccounts()
suspend fun saveToEncryptedStorage(account: Account) {
suspend fun saveToEncryptedStorage(settings: AccountSettings) {
Log.d("LocalPreferences", "Saving to encrypted storage")
withContext(Dispatchers.IO) {
checkNotInMainThread()
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))
val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
prefs
.edit()
.apply {
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.signer is NostrSignerExternal)
if (account.signer is NostrSignerExternal) {
remove(PrefKeys.NOSTR_PRIVKEY)
putString(PrefKeys.SIGNER_PACKAGE_NAME, account.signer.launcher.signerPackageName)
} else {
account.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
}
account.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(account.localRelays))
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, account.localRelayServers)
putString(
PrefKeys.LANGUAGE_PREFS,
Event.mapper.writeValueAsString(account.languagePreferences),
)
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(account.zapAmountChoices))
putString(
PrefKeys.REACTION_CHOICES,
Event.mapper.writeValueAsString(account.reactionChoices),
)
putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.name)
putString(
PrefKeys.DEFAULT_FILE_SERVER,
Event.mapper.writeValueAsString(account.defaultFileServer),
)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList.value)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList.value)
putString(
PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST,
account.defaultNotificationFollowList.value,
)
putString(
PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST,
account.defaultDiscoveryFollowList.value,
)
putString(
PrefKeys.ZAP_PAYMENT_REQUEST_SERVER,
Event.mapper.writeValueAsString(account.zapPaymentRequest),
)
if (account.backupContactList != null) {
putString(
PrefKeys.LATEST_CONTACT_LIST,
Event.mapper.writeValueAsString(account.backupContactList),
PrefKeys.DEFAULT_FILE_SERVER,
Event.mapper.writeValueAsString(settings.defaultFileServer),
)
} else {
remove(PrefKeys.LATEST_CONTACT_LIST)
}
if (account.backupDMRelayList != null) {
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, settings.defaultHomeFollowList.value)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, settings.defaultStoriesFollowList.value)
putString(
PrefKeys.LATEST_DM_RELAY_LIST,
Event.mapper.writeValueAsString(account.backupDMRelayList),
PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST,
settings.defaultNotificationFollowList.value,
)
} else {
remove(PrefKeys.LATEST_DM_RELAY_LIST)
}
if (account.backupNIP65RelayList != null) {
putString(
PrefKeys.LATEST_NIP65_RELAY_LIST,
Event.mapper.writeValueAsString(account.backupNIP65RelayList),
PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST,
settings.defaultDiscoveryFollowList.value,
)
} else {
remove(PrefKeys.LATEST_NIP65_RELAY_LIST)
}
putString(
PrefKeys.ZAP_PAYMENT_REQUEST_SERVER,
Event.mapper.writeValueAsString(settings.zapPaymentRequest),
)
if (settings.backupContactList != null) {
putString(
PrefKeys.LATEST_CONTACT_LIST,
Event.mapper.writeValueAsString(settings.backupContactList),
)
} else {
remove(PrefKeys.LATEST_CONTACT_LIST)
}
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, account.hideNIP17WarningDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
putBoolean(PrefKeys.WARN_ABOUT_REPORTS, account.warnAboutPostsWithReports)
putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, account.filterSpamFromStrangers)
putString(
PrefKeys.LAST_READ_PER_ROUTE,
Event.mapper.writeValueAsString(account.lastReadPerRoute),
)
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, account.hasDonatedInVersion)
if (settings.backupUserMetadata != null) {
putString(
PrefKeys.LATEST_USER_METADATA,
Event.mapper.writeValueAsString(settings.backupUserMetadata),
)
} else {
remove(PrefKeys.LATEST_USER_METADATA)
}
if (account.showSensitiveContent.value == null) {
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
} else {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.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(account.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.localRelayServers.isNotEmpty()) {
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers)
} else {
remove(PrefKeys.LOCAL_RELAY_SERVERS)
}
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)
}
if (settings.backupAppSpecificData != null) {
putString(
PrefKeys.LATEST_APP_SPECIFIC_DATA,
Event.mapper.writeValueAsString(settings.backupAppSpecificData),
)
} else {
remove(PrefKeys.LATEST_APP_SPECIFIC_DATA)
}
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()))
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)
putString(
PrefKeys.PENDING_ATTESTATIONS,
Event.mapper.writeValueAsString(settings.pendingAttestations.value),
)
}.apply()
}
}
Log.d("LocalPreferences", "Saved to encrypted storage")
}
suspend fun loadCurrentAccountFromEncryptedStorage(): Account? = currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
suspend fun loadCurrentAccountFromEncryptedStorage(): AccountSettings? = currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
suspend fun saveSharedSettings(
sharedSettings: Settings,
@@ -402,43 +477,40 @@ object LocalPreferences {
val mutex = Mutex()
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): Account? =
withContext(Dispatchers.IO) {
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): AccountSettings? {
// if already loaded, return right away
if (cachedAccounts.containsKey(npub)) {
return cachedAccounts[npub]
}
return withContext(Dispatchers.IO) {
mutex.withLock {
if (cachedAccounts.containsKey(npub)) {
return@withContext cachedAccounts.get(npub)
}
val account = innerLoadCurrentAccountFromEncryptedStorage(npub)
account?.registerObservers()
val accountSettings = innerLoadCurrentAccountFromEncryptedStorage(npub)
cachedAccounts.put(npub, account)
cachedAccounts.put(npub, accountSettings)
return@withContext account
return@withContext accountSettings
}
}
}
suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): Account? {
Log.d("LocalPreferences", "Load account from file")
private suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): AccountSettings? {
Log.d("LocalPreferences", "Load account from file $npub")
return withContext(Dispatchers.IO) {
checkNotInMainThread()
return@withContext with(encryptedPreferences(npub)) {
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null
val loginWithExternalSigner = getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)
val privKey = if (loginWithExternalSigner) null else getString(PrefKeys.NOSTR_PRIVKEY, null)
val externalSignerPackageName =
getString(PrefKeys.SIGNER_PACKAGE_NAME, null)
?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null
val localRelays =
getString(PrefKeys.RELAYS, "[]")?.let {
println("LocalRelays: $it")
Event.mapper.readValue<Set<RelaySetupInfo>?>(it)
}
?: setOf<RelaySetupInfo>()
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
val defaultHomeFollowList =
getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
val defaultStoriesFollowList =
@@ -448,242 +520,174 @@ object LocalPreferences {
val defaultDiscoveryFollowList =
getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val zapAmountChoices =
getString(PrefKeys.ZAP_AMOUNTS, "[]")
?.let { Event.mapper.readValue<List<Long>?>(it) }
?.ifEmpty { DefaultZapAmounts }
?: DefaultZapAmounts
val reactionChoices =
getString(PrefKeys.REACTION_CHOICES, "[]")
?.let { Event.mapper.readValue<List<String>?>(it) }
?.ifEmpty { DefaultReactions }
?: DefaultReactions
val defaultZapType =
getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName ->
LnZapEvent.ZapType.values().firstOrNull { it.name == serverName }
}
?: LnZapEvent.ZapType.PUBLIC
LnZapEvent.ZapType.entries.firstOrNull { it.name == serverName }
} ?: LnZapEvent.ZapType.PUBLIC
val defaultFileServer =
try {
getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName ->
Event.mapper.readValue<Nip96MediaServers.ServerName>(serverName)
}
?: Nip96MediaServers.DEFAULT[0]
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Failed to decode saved File Server", e)
e.printStackTrace()
Nip96MediaServers.DEFAULT[0]
}
val localRelays = parseOrNull<Set<RelaySetupInfo>>(PrefKeys.RELAYS) ?: emptySet()
val zapPaymentRequestServer =
try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
Event.mapper.readValue<Nip47WalletConnect.Nip47URI?>(it)
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Error Decoding Zap Payment Request Server ${getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)}",
e,
)
e.printStackTrace()
val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
val defaultFileServer = parseOrNull<ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0]
val pendingAttestations = parseOrNull<Map<HexKey, String>>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf()
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
val latestUserMetadata = parseEventOrNull<MetadataEvent>(PrefKeys.LATEST_USER_METADATA)
val latestContactList = parseEventOrNull<ContactListEvent>(PrefKeys.LATEST_CONTACT_LIST)
val latestDmRelayList = parseEventOrNull<ChatMessageRelayListEvent>(PrefKeys.LATEST_DM_RELAY_LIST)
val latestNip65RelayList = parseEventOrNull<AdvertisedRelayListEvent>(PrefKeys.LATEST_NIP65_RELAY_LIST)
val latestSearchRelayList = parseEventOrNull<SearchRelayListEvent>(PrefKeys.LATEST_SEARCH_RELAY_LIST)
val latestMuteList = parseEventOrNull<MuteListEvent>(PrefKeys.LATEST_MUTE_LIST)
val latestPrivateHomeRelayList = parseEventOrNull<PrivateOutboxRelayListEvent>(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
val latestAppSpecificData = parseEventOrNull<AppSpecificDataEvent>(PrefKeys.LATEST_APP_SPECIFIC_DATA)
val syncedSettings =
if (latestAppSpecificData != null) {
null
}
} else {
// previous version. Delete this when ready.
val reactionChoices = parseOrNull<List<String>>(PrefKeys.REACTION_CHOICES)?.ifEmpty { DefaultReactions } ?: DefaultReactions
val zapAmountChoices = parseOrNull<List<Long>>(PrefKeys.ZAP_AMOUNTS)?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts
val latestContactList =
try {
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
if (it != "null") {
println("Decoding Contact List: $it")
Event.fromJson(it) as ContactListEvent?
val languagePreferences = parseOrNull<Map<String, String>>(PrefKeys.LANGUAGE_PREFS) ?: mapOf()
val showSensitiveContent =
if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
} else {
null
}
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}",
e,
)
null
}
val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true)
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
val latestDmRelayList =
try {
getString(PrefKeys.LATEST_DM_RELAY_LIST, null)?.let {
if (it != "null") {
println("Decoding DM Relay List: $it")
Event.fromJson(it) as ChatMessageRelayListEvent?
} else {
null
}
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Error Decoding DM Relay List ${getString(PrefKeys.LATEST_DM_RELAY_LIST, null)}",
e,
)
null
}
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
val latestNip65RelayList =
try {
getString(PrefKeys.LATEST_NIP65_RELAY_LIST, null)?.let {
if (it != "null") {
println("Decoding NIP65 Relay List: $it")
Event.fromJson(it) as AdvertisedRelayListEvent?
} else {
null
}
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Error Decoding NIP65 Relay List ${getString(PrefKeys.LATEST_NIP65_RELAY_LIST, null)}",
e,
AccountSyncedSettingsInternal(
reactions =
AccountReactionPreferencesInternal(
reactionChoices = reactionChoices,
),
zaps =
AccountZapPreferencesInternal(
zapAmountChoices = zapAmountChoices,
defaultZapType = defaultZapType,
),
languages =
AccountLanguagePreferencesInternal(
dontTranslateFrom = dontTranslateFrom,
languagePreferences = languagePreferences,
translateTo = translateTo,
),
security =
AccountSecurityPreferencesInternal(
showSensitiveContent = showSensitiveContent,
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
),
)
null
}
val pendingAttestations =
try {
getString(PrefKeys.PENDING_ATTESTATIONS, null)?.let {
println("Decoding Attestation List: " + it)
if (it != null) {
Event.mapper.readValue<Map<HexKey, String>>(it)
} else {
null
}
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Error Decoding Contact List ${getString(PrefKeys.PENDING_ATTESTATIONS, null)}",
e,
)
null
}
val languagePreferences =
try {
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
Event.mapper.readValue<Map<String, String>?>(it)
}
?: mapOf()
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Error Decoding Language Preferences ${getString(PrefKeys.LANGUAGE_PREFS, null)}",
e,
)
e.printStackTrace()
mapOf()
}
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
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 showSensitiveContent =
if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
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 {
null
parseOrNull<TorSettings>(PrefKeys.TOR_SETTINGS) ?: TorSettings()
}
val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true)
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
val lastReadPerRoute =
try {
getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let {
Event.mapper.readValue<Map<String, Long>?>(it)
}
?: mapOf()
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Error Decoding Last Read per route ${getString(PrefKeys.LAST_READ_PER_ROUTE, null)}",
e,
)
e.printStackTrace()
mapOf()
}
parseOrNull<Map<String, Long>>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues {
MutableStateFlow(it.value)
} ?: mapOf()
val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray())
val signer =
if (loginWithExternalSigner) {
val packageName =
getString(PrefKeys.SIGNER_PACKAGE_NAME, null) ?: "com.greenart7c3.nostrsigner"
NostrSignerExternal(
pubKey,
ExternalSignerLauncher(pubKey.hexToByteArray().toNpub(), packageName),
)
} else {
NostrSignerInternal(keyPair)
}
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
val account =
Account(
keyPair = keyPair,
signer = signer,
localRelays = localRelays,
localRelayServers = localRelayServers,
dontTranslateFrom = dontTranslateFrom,
languagePreferences = languagePreferences,
translateTo = translateTo,
zapAmountChoices = zapAmountChoices,
reactionChoices = reactionChoices,
defaultZapType = defaultZapType,
defaultFileServer = defaultFileServer,
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList),
defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList),
zapPaymentRequest = zapPaymentRequestServer,
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP17WarningDialog = hideNIP17WarningDialog,
backupContactList = latestContactList,
backupNIP65RelayList = latestNip65RelayList,
backupDMRelayList = latestDmRelayList,
proxy = proxy,
proxyPort = proxyPort,
showSensitiveContent = MutableStateFlow(showSensitiveContent),
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
lastReadPerRoute = lastReadPerRoute,
hasDonatedInVersion = hasDonatedInVersion,
pendingAttestations = MutableStateFlow(pendingAttestations ?: emptyMap()),
)
// Loads from DB
account.userProfile()
withContext(Dispatchers.Main) {
// Loads Live Objects
account.userProfile().live()
}
return@with account
return@with AccountSettings(
keyPair = keyPair,
transientAccount = false,
externalSignerPackageName = externalSignerPackageName,
localRelays = localRelays,
localRelayServers = localRelayServers,
defaultFileServer = defaultFileServer,
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList),
defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList),
zapPaymentRequest = zapPaymentRequestServer,
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP17WarningDialog = hideNIP17WarningDialog,
backupUserMetadata = latestUserMetadata,
backupContactList = latestContactList,
backupNIP65RelayList = latestNip65RelayList,
backupDMRelayList = latestDmRelayList,
backupSearchRelayList = latestSearchRelayList,
backupPrivateHomeRelayList = latestPrivateHomeRelayList,
backupMuteList = latestMuteList,
backupAppSpecificData = latestAppSpecificData,
backupSyncedSettings = syncedSettings,
torSettings = TorSettingsFlow.build(torSettings),
lastReadPerRoute = MutableStateFlow(lastReadPerRoute),
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
pendingAttestations = MutableStateFlow(pendingAttestations),
)
}
}
}
private inline fun <reified T> SharedPreferences.parseOrNull(key: String): T? {
val value = getString(key, null)
if (value.isNullOrEmpty() || value == "null") {
return null
}
return try {
if (T::class.java.isInstance(Event::class.java)) {
Event.fromJson(value) as T?
} else {
Event.mapper.readValue<T?>(value)
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e)
e.printStackTrace()
null
}
}
private inline fun <reified T> SharedPreferences.parseEventOrNull(key: String): T? {
val value = getString(key, null)
if (value.isNullOrEmpty() || value == "null") {
return null
}
return try {
Event.fromJson(value) as T?
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e)
e.printStackTrace()
null
}
}
}

View File

@@ -0,0 +1,95 @@
/**
* 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 kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
/**
* Launches an async coroutine for each item, runs the
* function and waits for everybody to finsih
*/
suspend fun <T> launchAndWaitAll(
items: List<T>,
asyncFunc: suspend (T) -> Unit,
) {
coroutineScope {
val jobs =
items.map { next ->
async {
asyncFunc(next)
}
}
// runs in parallel to avoid overcrowding Amber.
withTimeoutOrNull(15000) {
jobs.joinAll()
}
}
}
/**
* Runs the function and waits for 10 seconds for any result.
*/
suspend inline fun <T> tryAndWait(
timeoutMillis: Long = 10000,
crossinline asyncFunc: (Continuation<T>) -> Unit,
): T? =
withTimeoutOrNull(timeoutMillis) {
suspendCancellableCoroutine { continuation ->
asyncFunc(continuation)
}
}
/**
* Runs an async coroutine for each one of the items,
* runs the request for that item,
* and gathers all the results in the output map.
*/
suspend fun <T, K> collectSuccessfulOperations(
items: List<T>,
runRequestFor: (T, (K) -> Unit) -> Unit,
output: MutableList<K> = mutableListOf(),
onReady: suspend (List<K>) -> Unit,
) {
if (items.isEmpty()) {
onReady(output)
return
}
launchAndWaitAll(items) {
val result =
tryAndWait { continuation ->
runRequestFor(it) { result: K -> continuation.resume(result) }
}
if (result != null) {
output.add(result)
}
}
onReady(output)
}

View File

@@ -23,13 +23,18 @@ package com.vitorpamplona.amethyst
import android.os.Build
import android.util.Log
import androidx.compose.runtime.Stable
import coil.Coil
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import coil.size.Precision
import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.size.Precision
import coil3.svg.SvgDecoder
import coil3.util.DebugLogger
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.Base64Fetcher
import com.vitorpamplona.amethyst.service.BlurHashFetcher
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
@@ -46,28 +51,37 @@ 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.service.HttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
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.NostrClient
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
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@Stable
class ServiceManager {
private var isStarted: Boolean =
false // to not open amber in a loop trying to use auth relays and registering for notifications
class ServiceManager(
val client: NostrClient,
val scope: CoroutineScope,
) {
// to not open amber in a loop trying to use auth relays and registering for notifications
private var isStarted: Boolean = false
private var account: Account? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var collectorJob: Job? = null
private fun start(account: Account) {
@@ -75,69 +89,110 @@ class ServiceManager {
start()
}
@OptIn(DelicateCoilApi::class)
private fun start() {
Log.d("ServiceManager", "Pre Starting Relay Services $isStarted $account")
if (isStarted && account != null) {
return
}
Log.d("ServiceManager", "Starting Relay Services")
Log.d("ServiceManager", "Starting Relay Services Tor: ${account?.settings?.torSettings?.torType?.value}")
val myAccount = account
// Resets Proxy Use
HttpClientManager.setDefaultProxy(account?.proxy)
HttpClientManager.setDefaultUserAgent("Amethyst/${BuildConfig.VERSION_NAME}")
LocalCache.antiSpam.active = account?.filterSpamFromStrangers ?: true
Coil.setImageLoader {
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)
}
// Convert this into a flow
LocalCache.antiSpam.active = account
?.settings
?.syncedSettings
?.security
?.filterSpamFromStrangers ?: true
SingletonImageLoader.setUnsafe {
Amethyst.instance
.imageLoaderBuilder()
.components {
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
add(AnimatedImageDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
} // .logger(DebugLogger())
.okHttpClient { HttpClientManager.getHttpClient() }
.precision(Precision.INEXACT)
.respectCacheHeaders(false)
add(Base64Fetcher.Factory)
add(BlurHashFetcher.Factory)
add(Base64Fetcher.BKeyer)
add(BlurHashFetcher.BKeyer)
add(
OkHttpNetworkFetcherFactory(
callFactory = {
myAccount?.shouldUseTorForImageDownload()?.let { HttpClientManager.getHttpClient(it) }
?: HttpClientManager.getHttpClient(false)
},
),
)
}.apply {
if (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark") {
this.logger(DebugLogger())
}
}.precision(Precision.INEXACT)
.build()
}
if (myAccount != null) {
val relaySet = myAccount.connectToRelays.value
Log.d("Relay", "Service Manager Connect Connecting ${relaySet.size}")
Client.reconnect(relaySet)
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)
client.reconnect(it, onlyIfChanged = true)
}
}
}
// start services
NostrAccountDataSource.account = myAccount
NostrAccountDataSource.otherAccounts =
LocalPreferences.allSavedAccounts().mapNotNull {
try {
it.npub.bechToBytes().toHexKey()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
}
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrVideoDataSource.account = myAccount
NostrDiscoveryDataSource.account = myAccount
NostrAccountDataSource.otherAccounts =
runBlocking {
LocalPreferences.allSavedAccounts().mapNotNull {
try {
it.npub.bechToBytes().toHexKey()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
}
}
// Notification Elements
NostrHomeDataSource.start()
NostrAccountDataSource.start()
@@ -180,7 +235,7 @@ class ServiceManager {
NostrUserProfileDataSource.stopSync()
NostrVideoDataSource.stopSync()
Client.reconnect(null)
client.reconnect(null)
isStarted = false
}
@@ -188,7 +243,7 @@ class ServiceManager {
LocalCache.cleanObservers()
}
fun trimMemory() {
suspend fun trimMemory() {
LocalCache.cleanObservers()
val accounts =

View File

@@ -0,0 +1,521 @@
/**
* 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.model
import android.util.Log
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
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.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.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AppSpecificDataEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.events.SearchRelayListEvent
import com.vitorpamplona.quartz.signers.ExternalSignerLauncher
import com.vitorpamplona.quartz.signers.NostrSignerExternal
import com.vitorpamplona.quartz.signers.NostrSignerInternal
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import java.util.Locale
val DefaultChannels =
setOf(
// Anigma's Nostr
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb",
// Amethyst's Group
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5",
)
val DefaultNIP65List =
listOf(
AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nostr.mom/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH),
AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nos.lol/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH),
AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nostr.bitcoiner.social/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH),
)
val DefaultDMRelayList =
listOf(
RelayUrlFormatter.normalize("wss://auth.nostr1.com"),
RelayUrlFormatter.normalize("wss://relay.0xchat.com"),
RelayUrlFormatter.normalize("wss://nos.lol"),
)
val DefaultSearchRelayList =
listOf(
RelayUrlFormatter.normalize("wss://relay.nostr.band"),
RelayUrlFormatter.normalize("wss://nostr.wine"),
RelayUrlFormatter.normalize("wss://relay.noswhere.com"),
RelayUrlFormatter.normalize("wss://search.nos.today"),
)
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val GLOBAL_FOLLOWS = " Global "
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val KIND3_FOLLOWS = " All Follows "
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val AROUND_ME = " Around Me "
@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(),
var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0],
val defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(KIND3_FOLLOWS),
val defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
val defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
val defaultDiscoveryFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
var zapPaymentRequest: Nip47WalletConnect.Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
var hideNIP17WarningDialog: Boolean = false,
var backupUserMetadata: MetadataEvent? = null,
var backupContactList: ContactListEvent? = null,
var backupDMRelayList: ChatMessageRelayListEvent? = null,
var backupNIP65RelayList: AdvertisedRelayListEvent? = null,
var backupSearchRelayList: SearchRelayListEvent? = null,
var backupMuteList: MuteListEvent? = null,
var backupPrivateHomeRelayList: PrivateOutboxRelayListEvent? = null,
var backupAppSpecificData: AppSpecificDataEvent? = null,
backupSyncedSettings: AccountSyncedSettingsInternal? = null, // only exist for migration purposes
val torSettings: TorSettingsFlow = TorSettingsFlow(),
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
var hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf<String>()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(mapOf()),
) {
val saveable = MutableStateFlow(AccountSettingsUpdater(this))
val syncedSettings: AccountSyncedSettings =
backupSyncedSettings?.let { AccountSyncedSettings(it) }
?: AccountSyncedSettings(AccountSyncedSettingsInternal())
class AccountSettingsUpdater(
val accountSettings: AccountSettings,
)
fun saveAccountSettings() {
saveable.update { AccountSettingsUpdater(this) }
}
fun isWriteable(): Boolean = keyPair.privKey != null || externalSignerPackageName != null
fun createSigner() =
if (keyPair.privKey != null) {
NostrSignerInternal(keyPair)
} else {
when (val packageName = externalSignerPackageName) {
null -> NostrSignerInternal(keyPair)
else -> {
val externalSignerLauncher = ExternalSignerLauncher(keyPair.pubKey.toHexKey(), packageName)
// TODO: How to handle the launcher here?
try {
externalSignerLauncher.registerLauncher(
launcher = { },
contentResolver = Amethyst.instance::contentResolverFn,
)
} catch (e: Exception) {
Log.d("AccountSettings", "Failed to initialize external signer", e)
}
NostrSignerExternal(keyPair.pubKey.toHexKey(), externalSignerLauncher)
}
}
}
// ---
// Zaps and Reactions
// ---
fun changeDefaultZapType(zapType: LnZapEvent.ZapType): Boolean {
if (syncedSettings.zaps.defaultZapType.value != zapType) {
syncedSettings.zaps.defaultZapType.tryEmit(zapType)
saveAccountSettings()
return true
}
return false
}
fun changeZapAmounts(newAmounts: List<Long>): Boolean {
if (syncedSettings.zaps.zapAmountChoices.value != newAmounts) {
syncedSettings.zaps.zapAmountChoices.tryEmit(newAmounts.toImmutableList())
saveAccountSettings()
return true
}
return false
}
fun changeReactionTypes(newTypes: List<String>): Boolean {
if (syncedSettings.reactions.reactionChoices.value != newTypes) {
syncedSettings.reactions.reactionChoices.tryEmit(newTypes.toImmutableList())
saveAccountSettings()
return true
}
return false
}
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?): Boolean {
if (zapPaymentRequest != newServer) {
zapPaymentRequest = newServer
saveAccountSettings()
return true
}
return false
}
// ---
// file servers
// ---
fun changeDefaultFileServer(server: ServerName) {
if (defaultFileServer != server) {
defaultFileServer = server
saveAccountSettings()
}
}
// ---
// list names
// ---
fun changeDefaultHomeFollowList(name: String) {
if (defaultHomeFollowList.value != name) {
defaultHomeFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultStoriesFollowList(name: String) {
if (defaultStoriesFollowList.value != name) {
defaultStoriesFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultNotificationFollowList(name: String) {
if (defaultNotificationFollowList.value != name) {
defaultNotificationFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultDiscoveryFollowList(name: String) {
if (defaultDiscoveryFollowList.value != name) {
defaultDiscoveryFollowList.tryEmit(name)
saveAccountSettings()
}
}
// ---
// proxy settings
// ---
fun setTorSettings(newTorSettings: TorSettings): Boolean {
if (torSettings.update(newTorSettings)) {
saveAccountSettings()
return true
} else {
return false
}
}
// ---
// language services
// ---
fun toggleDontTranslateFrom(languageCode: String) {
syncedSettings.languages.toggleDontTranslateFrom(languageCode)
saveAccountSettings()
}
fun translateToContains(languageCode: Locale) = syncedSettings.languages.translateTo.contains(languageCode.language)
fun updateTranslateTo(languageCode: Locale): Boolean {
if (syncedSettings.languages.updateTranslateTo(languageCode)) {
saveAccountSettings()
return true
}
return false
}
fun prefer(
source: String,
target: String,
preference: String,
) {
syncedSettings.languages.prefer(source, target, preference)
saveAccountSettings()
}
fun preferenceBetween(
source: String,
target: String,
): String? = syncedSettings.languages.preferenceBetween(source, target)
// ----
// Backup Lists
// ----
fun updateLocalRelayServers(servers: Set<String>) {
if (localRelayServers != servers) {
localRelayServers = servers
saveAccountSettings()
}
}
fun updateUserMetadata(newMetadata: MetadataEvent?) {
if (newMetadata == null) return
// Events might be different objects, we have to compare their ids.
if (backupUserMetadata?.id != newMetadata.id) {
backupUserMetadata = newMetadata
saveAccountSettings()
}
}
fun updateContactListTo(newContactList: ContactListEvent?) {
if (newContactList == null || newContactList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupContactList?.id != newContactList.id) {
backupContactList = newContactList
saveAccountSettings()
}
}
fun updateDMRelayList(newDMRelayList: ChatMessageRelayListEvent?) {
if (newDMRelayList == null || newDMRelayList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupDMRelayList?.id != newDMRelayList.id) {
backupDMRelayList = newDMRelayList
saveAccountSettings()
}
}
fun updateNIP65RelayList(newNIP65RelayList: AdvertisedRelayListEvent?) {
if (newNIP65RelayList == null || newNIP65RelayList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupNIP65RelayList?.id != newNIP65RelayList.id) {
backupNIP65RelayList = newNIP65RelayList
saveAccountSettings()
}
}
fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) {
if (newSearchRelayList == null || newSearchRelayList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupSearchRelayList?.id != newSearchRelayList.id) {
backupSearchRelayList = newSearchRelayList
saveAccountSettings()
}
}
fun updatePrivateHomeRelayList(newPrivateHomeRelayList: PrivateOutboxRelayListEvent?) {
if (newPrivateHomeRelayList == null || newPrivateHomeRelayList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupPrivateHomeRelayList?.id != newPrivateHomeRelayList.id) {
backupPrivateHomeRelayList = newPrivateHomeRelayList
saveAccountSettings()
}
}
fun updateMuteList(newMuteList: MuteListEvent?) {
if (newMuteList == null || newMuteList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupMuteList?.id != newMuteList.id) {
backupMuteList = newMuteList
saveAccountSettings()
}
}
fun updateAppSpecificData(
appSettings: AppSpecificDataEvent?,
newSyncedSettings: AccountSyncedSettingsInternal,
) {
if (appSettings == null || appSettings.content().isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupAppSpecificData?.id != appSettings.id) {
backupAppSpecificData = appSettings
syncedSettings.updateFrom(newSyncedSettings)
saveAccountSettings()
}
}
// ----
// Warning dialogs
// ----
fun setHideDeleteRequestDialog() {
if (!hideDeleteRequestDialog) {
hideDeleteRequestDialog = true
saveAccountSettings()
}
}
fun setHideNIP17WarningDialog() {
if (!hideNIP17WarningDialog) {
hideNIP17WarningDialog = true
saveAccountSettings()
}
}
fun setHideBlockAlertDialog() {
if (!hideBlockAlertDialog) {
hideBlockAlertDialog = true
saveAccountSettings()
}
}
// ---
// donations
// ---
fun hasDonatedInVersion(versionName: String) = hasDonatedInVersion.value.contains(versionName)
fun observeDonatedInVersion(versionName: String) =
hasDonatedInVersion
.map {
it.contains(versionName)
}
fun markDonatedInThisVersion(versionName: String): Boolean {
if (!hasDonatedInVersion.value.contains(versionName)) {
hasDonatedInVersion.update {
it + versionName
}
saveAccountSettings()
return true
}
return false
}
// ----
// last read flows
// ----
fun getLastReadFlow(route: String): StateFlow<Long> = lastReadPerRoute.value[route] ?: addLastRead(route, 0)
private fun addLastRead(
route: String,
timestampInSecs: Long,
): MutableStateFlow<Long> =
MutableStateFlow<Long>(timestampInSecs).also { newFlow ->
lastReadPerRoute.update { it + Pair(route, newFlow) }
saveAccountSettings()
}
fun markAsRead(
route: String,
timestampInSecs: Long,
): Boolean {
val lastTime = lastReadPerRoute.value[route]
return if (lastTime == null) {
addLastRead(route, timestampInSecs)
true
} else if (timestampInSecs > lastTime.value) {
lastTime.tryEmit(timestampInSecs)
saveAccountSettings()
true
} else {
false
}
}
// ----
// local relays
// ----
fun updateLocalRelays(newLocalRelays: Set<RelaySetupInfo>) {
if (!localRelays.equals(newLocalRelays)) {
localRelays = newLocalRelays
saveAccountSettings()
}
}
// ---
// attestations
// ---
fun addPendingAttestation(
id: HexKey,
stamp: String,
) {
val current = pendingAttestations.value.get(id)
if (current == null) {
pendingAttestations.update {
it + Pair(id, stamp)
}
saveAccountSettings()
} else {
if (current != stamp) {
pendingAttestations.update {
it + Pair(id, stamp)
}
saveAccountSettings()
}
}
}
// ---
// filters
// ---
fun updateShowSensitiveContent(show: Boolean?): Boolean {
if (syncedSettings.security.updateShowSensitiveContent(show)) {
saveAccountSettings()
return true
}
return false
}
fun updateOptOutOptions(
warnReports: Boolean,
filterSpam: Boolean,
): Boolean =
if (syncedSettings.security.updateOptOutOptions(warnReports, filterSpam)) {
saveAccountSettings()
true
} else {
false
}
}

View File

@@ -0,0 +1,208 @@
/**
* 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.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.quartz.events.LnZapEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import java.util.Locale
@Stable
class AccountSyncedSettings(
internalSettings: AccountSyncedSettingsInternal,
) {
val reactions = AccountReactionPreferences(MutableStateFlow(internalSettings.reactions.reactionChoices.toImmutableList()))
val zaps =
AccountZapPreferences(
MutableStateFlow(internalSettings.zaps.zapAmountChoices.toImmutableList()),
MutableStateFlow(internalSettings.zaps.defaultZapType),
)
val languages =
AccountLanguagePreferences(
internalSettings.languages.dontTranslateFrom,
internalSettings.languages.languagePreferences,
internalSettings.languages.translateTo,
)
val security =
AccountSecurityPreferences(
MutableStateFlow(internalSettings.security.showSensitiveContent),
internalSettings.security.warnAboutPostsWithReports,
internalSettings.security.filterSpamFromStrangers,
)
fun toInternal(): AccountSyncedSettingsInternal =
AccountSyncedSettingsInternal(
reactions = AccountReactionPreferencesInternal(reactions.reactionChoices.value),
zaps =
AccountZapPreferencesInternal(
zaps.zapAmountChoices.value,
zaps.defaultZapType.value,
),
languages =
AccountLanguagePreferencesInternal(
languages.dontTranslateFrom,
languages.languagePreferences,
languages.translateTo,
),
security =
AccountSecurityPreferencesInternal(
security.showSensitiveContent.value,
security.warnAboutPostsWithReports,
security.filterSpamFromStrangers,
),
)
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
val newReactionChoices = syncedSettingsInternal.reactions.reactionChoices.toImmutableList()
if (!equalImmutableLists(reactions.reactionChoices.value, newReactionChoices)) {
reactions.reactionChoices.tryEmit(newReactionChoices)
}
val newZapChoices = syncedSettingsInternal.zaps.zapAmountChoices.toImmutableList()
if (!equalImmutableLists(zaps.zapAmountChoices.value, newZapChoices)) {
zaps.zapAmountChoices.tryEmit(newZapChoices)
}
if (zaps.defaultZapType.value != syncedSettingsInternal.zaps.defaultZapType) {
zaps.defaultZapType.tryEmit(syncedSettingsInternal.zaps.defaultZapType)
}
if (languages.dontTranslateFrom != syncedSettingsInternal.languages.dontTranslateFrom) {
languages.dontTranslateFrom = syncedSettingsInternal.languages.dontTranslateFrom
}
if (languages.languagePreferences != syncedSettingsInternal.languages.languagePreferences) {
languages.languagePreferences = syncedSettingsInternal.languages.languagePreferences
}
if (languages.translateTo != syncedSettingsInternal.languages.translateTo) {
languages.translateTo = syncedSettingsInternal.languages.translateTo
}
if (security.showSensitiveContent.value != syncedSettingsInternal.security.showSensitiveContent) {
security.showSensitiveContent.tryEmit(syncedSettingsInternal.security.showSensitiveContent)
}
if (security.filterSpamFromStrangers != syncedSettingsInternal.security.filterSpamFromStrangers) {
security.filterSpamFromStrangers = syncedSettingsInternal.security.filterSpamFromStrangers
}
if (security.warnAboutPostsWithReports != syncedSettingsInternal.security.warnAboutPostsWithReports) {
security.warnAboutPostsWithReports = syncedSettingsInternal.security.warnAboutPostsWithReports
}
}
}
@Stable
class AccountReactionPreferences(
var reactionChoices: MutableStateFlow<ImmutableList<String>>,
)
@Stable
class AccountZapPreferences(
var zapAmountChoices: MutableStateFlow<ImmutableList<Long>>,
val defaultZapType: MutableStateFlow<LnZapEvent.ZapType>,
)
@Stable
class AccountLanguagePreferences(
var dontTranslateFrom: Set<String>,
var languagePreferences: Map<String, String>,
var translateTo: String,
) {
// ---
// language services
// ---
fun toggleDontTranslateFrom(languageCode: String) {
if (!dontTranslateFrom.contains(languageCode)) {
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
} else {
dontTranslateFrom = dontTranslateFrom.minus(languageCode)
}
}
fun translateToContains(languageCode: Locale) = translateTo.contains(languageCode.language)
fun updateTranslateTo(languageCode: Locale): Boolean {
if (translateTo != languageCode.language) {
translateTo = languageCode.language
return true
}
return false
}
fun prefer(
source: String,
target: String,
preference: String,
) {
val key = "$source,$target"
if (key !in languagePreferences) {
languagePreferences = languagePreferences + Pair(key, preference)
} else {
if (languagePreferences.get(key) == preference) {
languagePreferences = languagePreferences.minus(key)
} else {
languagePreferences = languagePreferences + Pair(key, preference)
}
}
}
fun preferenceBetween(
source: String,
target: String,
): String? = languagePreferences["$source,$target"]
}
@Stable
class AccountSecurityPreferences(
val showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null),
var warnAboutPostsWithReports: Boolean = true,
var filterSpamFromStrangers: Boolean = true,
) {
fun updateShowSensitiveContent(show: Boolean?): Boolean {
if (showSensitiveContent.value != show) {
showSensitiveContent.update { show }
return true
}
return false
}
// ---
// filters
// ---
fun updateOptOutOptions(
warnReports: Boolean,
filterSpam: Boolean,
): Boolean =
if (warnAboutPostsWithReports != warnReports || filterSpam != filterSpamFromStrangers) {
warnAboutPostsWithReports = warnReports
filterSpamFromStrangers = filterSpam
true
} else {
false
}
}

View File

@@ -0,0 +1,76 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat
import com.vitorpamplona.quartz.events.LnZapEvent
import java.util.Locale
val DefaultReactions =
listOf(
"\uD83D\uDE80",
"\uD83E\uDEC2",
"\uD83D\uDC40",
"\uD83D\uDE02",
"\uD83C\uDF89",
"\uD83E\uDD14",
"\uD83D\uDE31",
)
val DefaultZapAmounts = listOf(100L, 500L, 1000L)
fun getLanguagesSpokenByUser(): Set<String> {
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
val codedList = mutableSetOf<String>()
for (i in 0 until languageList.size()) {
languageList.get(i)?.let { codedList.add(it.language) }
}
return codedList
}
class AccountSyncedSettingsInternal(
val reactions: AccountReactionPreferencesInternal = AccountReactionPreferencesInternal(),
val zaps: AccountZapPreferencesInternal = AccountZapPreferencesInternal(),
val languages: AccountLanguagePreferencesInternal = AccountLanguagePreferencesInternal(),
val security: AccountSecurityPreferencesInternal = AccountSecurityPreferencesInternal(),
)
class AccountReactionPreferencesInternal(
var reactionChoices: List<String> = DefaultReactions,
)
class AccountZapPreferencesInternal(
var zapAmountChoices: List<Long> = DefaultZapAmounts,
val defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
)
class AccountLanguagePreferencesInternal(
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
var languagePreferences: Map<String, String> = mapOf(),
var translateTo: String = Locale.getDefault().language,
)
class AccountSecurityPreferencesInternal(
val showSensitiveContent: Boolean? = null,
var warnAboutPostsWithReports: Boolean = true,
var filterSpamFromStrangers: Boolean = true,
)

View File

@@ -22,16 +22,14 @@ package com.vitorpamplona.amethyst.model
import android.util.Log
import android.util.LruCache
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.njumpLink
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.RelayStats
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.events.Event
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
data class Spammer(
val pubkeyHex: HexKey,
@@ -82,10 +80,10 @@ class AntiSpamFilter {
logOffender(hash, event)
if (relay != null) {
RelayStats.newSpam(relay.url, "https://njump.me/${Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, relay.url)}")
RelayStats.newSpam(relay.url, njumpLink(Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, relay.url)))
}
liveSpam.invalidateData()
flowSpam.tryEmit(AntiSpamState(this))
return true
}
@@ -108,27 +106,7 @@ class AntiSpamFilter {
}
}
val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this)
}
@Stable
class AntiSpamLiveData(
val cache: AntiSpamFilter,
) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate {
checkNotInMainThread()
if (hasActiveObservers()) {
postValue(AntiSpamState(cache))
}
}
}
val flowSpam = MutableStateFlow<AntiSpamState>(AntiSpamState(this))
}
class AntiSpamState(

View File

@@ -31,7 +31,7 @@ import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toNote
import com.vitorpamplona.quartz.encoders.toNEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import kotlinx.coroutines.Dispatchers
@@ -107,7 +107,7 @@ abstract class Channel(
open fun id() = Hex.decode(idHex)
open fun idNote() = id().toNote()
open fun idNote() = id().toNEvent()
open fun idDisplayNote() = idNote().toShortenHex()

View File

@@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@Stable
@@ -75,7 +77,7 @@ class Chatroom {
fun senderIntersects(keySet: Set<HexKey>): Boolean = authors.any { it.pubkeyHex in keySet }
fun pruneMessagesToTheLatestOnly(): Set<Note> {
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
val sorted = roomMessages.sortedWith(DefaultFeedOrder)
val toKeep =
if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) {
@@ -84,7 +86,7 @@ class Chatroom {
} else {
// Old messages, keep the last one.
sorted.take(1).toSet()
} + sorted.filter { it.liveSet?.isInUse() ?: false }
} + sorted.filter { it.liveSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent }
val toRemove = roomMessages.minus(toKeep)
roomMessages = toKeep

View File

@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.Coffee
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.hashtags.Footstr
import com.vitorpamplona.amethyst.commons.hashtags.Gamestr
import com.vitorpamplona.amethyst.commons.hashtags.Grownostr
import com.vitorpamplona.amethyst.commons.hashtags.Lightning
import com.vitorpamplona.amethyst.commons.hashtags.Mate
@@ -47,21 +48,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",
"Testing rendering of hashtags: #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate, #gamestr, #gamechain",
EmptyTagList,
) { word, state ->
when (word) {
is HashTagSegment -> HashTag(word, nav)
is HashTagSegment -> HashTag(word, EmptyNav)
is RegularTextSegment -> Text(word.segmentText)
}
}
@@ -84,6 +84,7 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? =
"tunestr", "music", "nowplaying" -> tunestr
"mate", "matechain", "matestr" -> matestr
"weed", "weedstr", "420", "cannabis", "marijuana" -> weed
"gamestr", "gaming", "gamechain" -> gamestr
else -> null
}
@@ -101,6 +102,7 @@ val footstr = HashtagIcon(CustomHashTagIcons.Footstr, "Footstr", Modifier.paddin
val tunestr = HashtagIcon(CustomHashTagIcons.Tunestr, "Tunestr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val weed = HashtagIcon(CustomHashTagIcons.Weed, "Weed", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
val matestr = HashtagIcon(CustomHashTagIcons.Mate, "Mate", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
val gamestr = HashtagIcon(CustomHashTagIcons.Gamestr, "GameStr", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
@Immutable
class HashtagIcon(

View File

@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
import androidx.compose.runtime.Stable
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.fasterxml.jackson.databind.DeserializationFeature
@@ -40,11 +41,13 @@ import kotlinx.collections.immutable.toImmutableMap
JsonSubTypes.Type(value = Bundle::class, name = "Bundle"),
JsonSubTypes.Type(value = VisionPrescription::class, name = "VisionPrescription"),
)
@Stable
open class Resource(
var resourceType: String? = null,
var id: String = "",
)
@Stable
class Practitioner(
resourceType: String? = null,
id: String = "",
@@ -53,6 +56,7 @@ class Practitioner(
var gender: String? = null,
) : Resource(resourceType, id)
@Stable
class Patient(
resourceType: String? = null,
id: String = "",
@@ -61,6 +65,7 @@ class Patient(
var gender: String? = null,
) : Resource(resourceType, id)
@Stable
class HumanName(
var use: String? = null,
var family: String? = null,
@@ -69,6 +74,7 @@ class HumanName(
fun assembleName(): String = given.joinToString(" ") + " " + family
}
@Stable
class Bundle(
resourceType: String? = null,
id: String = "",
@@ -77,6 +83,7 @@ class Bundle(
var entry: List<Resource> = arrayListOf(),
) : Resource(resourceType, id)
@Stable
class VisionPrescription(
resourceType: String? = null,
id: String = "",
@@ -87,15 +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,
@@ -105,6 +128,7 @@ class LensSpecification(
var note: String? = null,
)
@Stable
class Prism(
var amount: Double? = null,
var base: String? = null,

View File

@@ -25,22 +25,23 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.distinctUntilChanged
import com.vitorpamplona.amethyst.launchAndWaitAll
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.amethyst.ui.actions.relays.updated
import com.vitorpamplona.amethyst.ui.note.combineWith
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.toNote
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
@@ -67,19 +68,20 @@ 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.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.flow.flatMapLatest
import java.math.BigDecimal
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
@Stable
class AddressableNote(
val address: ATag,
) : Note(address.toTag()) {
override fun idNote() = address.toNAddr()
override fun idNote() = address.toNAddr(relayHintUrl())
override fun toNEvent() = address.toNAddr()
override fun toNEvent() = address.toNAddr(relayHintUrl())
override fun idDisplayNote() = idNote().toShortenHex()
@@ -94,7 +96,7 @@ class AddressableNote(
return minOf(publishedAt, lastCreatedAt)
}
fun dTag(): String? = (event as? AddressableEvent)?.dTag()
fun dTag(): String = address.dTag
override fun wasOrShouldBeDeletedBy(
deletionEvents: Set<HexKey>,
@@ -139,13 +141,11 @@ open class Note(
var relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
private set
var associatedNote: Note? = null
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
fun id() = Hex.decode(idHex)
open fun idNote() = id().toNote()
open fun idNote() = toNEvent()
open fun toNEvent(): String {
val myEvent = event
@@ -156,13 +156,27 @@ open class Note(
host.id,
host.pubKey,
host.kind,
relays.firstOrNull()?.url,
relayHintUrl(),
)
} else {
Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relayHintUrl())
}
} else {
Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relayHintUrl())
}
}
fun relayHintUrl(): String? {
val authorRelay = author?.latestMetadataRelay
return if (relays.isNotEmpty()) {
if (authorRelay != null && relays.any { it.url == authorRelay }) {
authorRelay
} else {
relays.firstOrNull()?.url
}
} else {
null
}
}
@@ -423,31 +437,36 @@ open class Note(
}
}
private fun recursiveIsPaidByCalculation(
private suspend fun isPaidByCalculation(
account: Account,
remainingZapPayments: List<Pair<Note, Note?>>,
zapEvents: List<Pair<Note, Note?>>,
onWasZappedByAuthor: () -> Unit,
) {
if (remainingZapPayments.isEmpty()) {
if (zapEvents.isEmpty()) {
return
}
val next = remainingZapPayments.first()
var hasSentOne = false
val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent
if (zapResponseEvent != null) {
account.decryptZapPaymentResponseEvent(zapResponseEvent) { response ->
if (
response is PayInvoiceSuccessResponse &&
account.isNIP47Author(zapResponseEvent.requestAuthor())
) {
launchAndWaitAll(zapEvents) { next ->
val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent
if (zapResponseEvent != null) {
val result =
tryAndWait { continuation ->
account.decryptZapPaymentResponseEvent(zapResponseEvent) { response ->
if (
response is PayInvoiceSuccessResponse &&
account.isNIP47Author(zapResponseEvent.requestAuthor())
) {
continuation.resume(true)
}
}
}
if (!hasSentOne && result == true) {
hasSentOne = true
onWasZappedByAuthor()
} else {
recursiveIsPaidByCalculation(
account,
remainingZapPayments.minus(next),
onWasZappedByAuthor,
)
}
}
}
@@ -457,14 +476,14 @@ open class Note(
option: Int?,
user: User,
account: Account,
remainingZapEvents: Map<Note, Note?>,
zapEvents: Map<Note, Note?>,
onWasZappedByAuthor: () -> Unit,
) {
if (remainingZapEvents.isEmpty()) {
if (zapEvents.isEmpty()) {
return
}
remainingZapEvents.forEach { next ->
zapEvents.forEach { next ->
val zapRequest = next.key.event as LnZapRequestEvent
val zapEvent = next.value?.event as? LnZapEvent
@@ -487,11 +506,9 @@ open class Note(
} else {
if (account.isWriteable()) {
val result =
withTimeoutOrNull(1000) {
suspendCancellableCoroutine { continuation ->
zapRequest.decryptPrivateZap(account.signer) {
continuation.resume(it)
}
tryAndWait { continuation ->
zapRequest.decryptPrivateZap(account.signer) {
continuation.resume(it)
}
}
@@ -512,7 +529,7 @@ open class Note(
) {
isZappedByCalculation(null, user, account, zaps, onWasZappedByAuthor)
if (account.userProfile() == user) {
recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor)
isPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor)
}
}
@@ -564,23 +581,79 @@ open class Note(
zapsAmount = sumOfAmounts
}
private fun recursiveZappedAmountCalculation(
invoiceSet: LinkedHashSet<String>,
remainingZapPayments: List<Pair<Note, Note?>>,
private suspend fun zappedAmountCalculation(
startAmount: BigDecimal,
paidInvoiceSet: LinkedHashSet<String>,
zapPayments: List<Pair<Note, Note?>>,
signer: NostrSigner,
output: BigDecimal,
onReady: (BigDecimal) -> Unit,
) {
if (remainingZapPayments.isEmpty()) {
onReady(output)
if (zapPayments.isEmpty()) {
onReady(startAmount)
return
}
val next = remainingZapPayments.first()
var output: BigDecimal = startAmount
(next.second?.event as? LnZapPaymentResponseEvent)?.response(signer) { noteEvent ->
launchAndWaitAll(zapPayments) { next ->
val result =
tryAndWait { continuation ->
processZapAmountFromResponse(
next.first,
next.second,
continuation,
signer,
)
}
if (result != null && !paidInvoiceSet.contains(result.invoice)) {
paidInvoiceSet.add(result.invoice)
output = output.add(result.amount)
}
}
onReady(output)
}
private fun processZapAmountFromResponse(
paymentRequest: Note,
paymentResponse: Note?,
continuation: Continuation<InvoiceAmount?>,
signer: NostrSigner,
) {
val nwcRequest = paymentRequest.event as? LnZapPaymentRequestEvent
val nwcResponse = paymentResponse?.event as? LnZapPaymentResponseEvent
if (nwcRequest != null && nwcResponse != null) {
processZapAmountFromResponse(
nwcRequest,
nwcResponse,
continuation,
signer,
)
} else {
continuation.resume(null)
}
}
class InvoiceAmount(
val invoice: String,
val amount: BigDecimal,
)
private fun processZapAmountFromResponse(
nwcRequest: LnZapPaymentRequestEvent,
nwcResponse: LnZapPaymentResponseEvent,
continuation: Continuation<InvoiceAmount?>,
signer: NostrSigner,
) {
// if we can decrypt the reply
nwcResponse.response(signer) { noteEvent ->
// if it is a sucess
if (noteEvent is PayInvoiceSuccessResponse) {
(next.first.event as? LnZapPaymentRequestEvent)?.lnInvoice(signer) { invoice ->
// if we can decrypt the invoice
nwcRequest.lnInvoice(signer) { invoice ->
// if we can parse the amount
val amount =
try {
LnInvoiceUtil.getAmountInSats(invoice)
@@ -589,26 +662,20 @@ open class Note(
null
}
var newAmount = output
if (amount != null && !invoiceSet.contains(invoice)) {
invoiceSet.add(invoice)
newAmount += amount
// avoid double counting
if (amount != null) {
continuation.resume(InvoiceAmount(invoice, amount))
} else {
continuation.resume(null)
}
recursiveZappedAmountCalculation(
invoiceSet,
remainingZapPayments.minus(next),
signer,
newAmount,
onReady,
)
}
} else {
continuation.resume(null)
}
}
}
fun zappedAmountWithNWCPayments(
suspend fun zappedAmountWithNWCPayments(
signer: NostrSigner,
onReady: (BigDecimal) -> Unit,
) {
@@ -619,11 +686,11 @@ open class Note(
val invoiceSet = LinkedHashSet<String>(zaps.size + zapPayments.size)
zaps.forEach { (it.value?.event as? LnZapEvent)?.lnInvoice()?.let { invoiceSet.add(it) } }
recursiveZappedAmountCalculation(
zappedAmountCalculation(
zapsAmount,
invoiceSet,
zapPayments.toList(),
signer,
zapsAmount,
onReady,
)
}
@@ -766,10 +833,16 @@ 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 (thisEvent.anyHashTag { it.containsAny(accountChoices.hiddenWordsCase) }) {
return true
}
if (author?.containsAny(accountChoices.hiddenWordsCase) == true) return true
}
return false
@@ -850,6 +923,15 @@ class NoteFlowSet(
val reports = NoteBundledRefresherFlow(u)
val relays = NoteBundledRefresherFlow(u)
@OptIn(ExperimentalCoroutinesApi::class)
fun author() =
metadata.stateFlow.flatMapLatest {
it.note.author
?.flow()
?.metadata
?.stateFlow ?: MutableStateFlow(null)
}
fun isInUse(): Boolean =
metadata.stateFlow.subscriptionCount.value > 0 ||
reports.stateFlow.subscriptionCount.value > 0 ||

View File

@@ -20,12 +20,14 @@
*/
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.RepostEvent
import kotlin.time.measureTimedValue
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet
class ThreadAssembler {
private fun searchRoot(
@@ -71,32 +73,50 @@ class ThreadAssembler {
return null
}
fun findThreadFor(noteId: String): Set<Note> {
@Stable
class ThreadInfo(
val root: Note,
val allNotes: ImmutableSet<Note>,
)
fun findThreadFor(noteId: String): ThreadInfo? {
checkNotInMainThread()
val (result, elapsed) =
measureTimedValue {
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet<Note>()
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return null
if (note.event != null) {
val thread = OnlyLatestVersionSet()
return if (note.event != null) {
val thread = OnlyLatestVersionSet()
val threadRoot = searchRoot(note, thread) ?: note
val threadRoot = searchRoot(note, thread) ?: note
loadDown(threadRoot, thread)
// adds the replies of the note in case the search for Root
// did not added them.
note.replies.forEach { loadDown(it, thread) }
loadUp(note, thread)
thread
} else {
setOf(note)
}
}
loadDown(threadRoot, thread)
// adds the replies of the note in case the search for Root
// did not added them.
note.replies.forEach { loadDown(it, thread) }
println("Model Refresh: Thread loaded in $elapsed")
ThreadInfo(
root = note,
allNotes = thread.toImmutableSet(),
)
} else {
ThreadInfo(
root = note,
allNotes = setOf(note).toImmutableSet(),
)
}
}
return result
fun loadUp(
note: Note,
thread: MutableSet<Note>,
) {
if (note !in thread) {
thread.add(note)
note.replyTo?.forEach { loadUp(it, thread) }
}
}
fun loadDown(

View File

@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.RepostEvent
import java.lang.Long.min
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -54,13 +55,23 @@ object ThreadLevelCalculator {
now: Long,
): LevelSignature {
val replyTo = note.replyTo
// estimates the min date by replies if it doesn't exist.
val createdAt =
note.createdAt() ?: min(
note.replies.minOfOrNull { it.createdAt() ?: now } ?: now,
note.reactions.values.minOfOrNull { it.minOfOrNull { it.createdAt() ?: now } ?: now } ?: now,
)
val noteAuthor = note.author
if (
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
return LevelSignature(
signature = "/" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) + ";",
createdAt = note.createdAt(),
author = note.author,
signature = "/" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) + ";",
createdAt = createdAt,
author = noteAuthor,
)
}
@@ -86,23 +97,21 @@ object ThreadLevelCalculator {
val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
val threadOrder =
if (parent?.author == note.author && note.createdAt() != null) {
if (noteAuthor != null && parent?.author == noteAuthor) {
// author of the thread first, in **ascending** order
"9" +
formattedDateTime((parent?.createdAt ?: 0) + (now - (note.createdAt() ?: 0))) +
note.idHex.substring(0, 8)
} else if (note.author?.pubkeyHex == account.pubkeyHex) {
"8" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my replies
} else if (note.author?.pubkeyHex in accountFollowingSet) {
"7" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my follows replies.
"9" + formattedDateTime((parent.createdAt ?: 0) + (now - createdAt)) + note.idHex.substring(0, 8)
} else if (noteAuthor != null && noteAuthor.pubkeyHex == account.pubkeyHex) {
"8" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // my replies
} else if (noteAuthor != null && noteAuthor.pubkeyHex in accountFollowingSet) {
"7" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // my follows replies.
} else {
"0" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // everyone else.
"0" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // everyone else.
}
val mySignature =
LevelSignature(
signature = parentSignature + "/" + threadOrder + ";",
createdAt = note.createdAt(),
signature = "$parentSignature/$threadOrder;",
createdAt = createdAt,
author = note.author,
)
@@ -118,11 +127,17 @@ object ThreadLevelCalculator {
if (
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
cachedLevels[note] = 0
return 0
}
return replyTo.maxOf {
cachedLevels[it] ?: replyLevel(it, cachedLevels).apply { cachedLevels.put(it, this) }
} + 1
val thisLevel =
replyTo.maxOf {
cachedLevels[it] ?: replyLevel(it, cachedLevels)
} + 1
cachedLevels[note] = thisLevel
return thisLevel
}
}

View File

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

View File

@@ -29,8 +29,8 @@ import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Lud06
@@ -45,6 +45,8 @@ import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -331,57 +333,20 @@ class User(
}
}
flowSet?.metadata?.invalidateData()
liveSet?.innerMetadata?.invalidateData()
}
fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
fun isFollowingHashtag(tag: String): Boolean = latestContactList?.isTaggedHash(tag) ?: false
fun isFollowingHashtag(tag: String) = latestContactList?.isTaggedHash(tag) ?: false
fun isFollowingHashtagCached(tag: String): Boolean {
return latestContactList?.verifiedFollowTagSet?.let {
return tag.lowercase() in it
}
?: false
}
fun isFollowingGeohashCached(geoTag: String): Boolean {
return latestContactList?.verifiedFollowGeohashSet?.let {
return geoTag.lowercase() in it
}
?: false
}
fun isFollowingCached(user: User): Boolean {
return latestContactList?.verifiedFollowKeySet?.let {
return user.pubkeyHex in it
}
?: false
}
fun isFollowingCached(userHex: String): Boolean {
return latestContactList?.verifiedFollowKeySet?.let {
return userHex in it
}
?: false
}
fun isFollowingGeohash(geoTag: String) = latestContactList?.isTaggedGeoHash(geoTag) ?: false
fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size
suspend fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
fun cachedFollowingKeySet(): Set<HexKey> = latestContactList?.verifiedFollowKeySet ?: emptySet()
fun cachedFollowingTagSet(): Set<String> = latestContactList?.verifiedFollowTagSet ?: emptySet()
fun cachedFollowingGeohashSet(): Set<HexKey> = latestContactList?.verifiedFollowGeohashSet ?: emptySet()
fun cachedFollowingCommunitiesSet(): Set<HexKey> = latestContactList?.verifiedFollowCommunitySet ?: emptySet()
fun cachedFollowCount(): Int? = latestContactList?.verifiedFollowKeySet?.size
suspend fun cachedFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
val messagesToUser = privateChatrooms[key] ?: return false
@@ -397,6 +362,40 @@ class User(
(it.event as ReportEvent).reportedAuthor().any { it.reportType == type }
} != null
fun containsAny(hiddenWordsCase: List<DualCase>): Boolean {
if (hiddenWordsCase.isEmpty()) return false
if (toBestDisplayName().containsAny(hiddenWordsCase)) {
return true
}
if (profilePicture()?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.banner?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.about?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.lud06?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.lud16?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.nip05?.containsAny(hiddenWordsCase) == true) {
return true
}
return false
}
fun anyNameStartsWith(username: String): Boolean = info?.anyNameStartsWith(username) ?: false
var liveSet: UserLiveSet? = null
@@ -462,12 +461,17 @@ class UserFlowSet(
u: User,
) {
// Observers line up here.
val metadata = UserBundledRefresherFlow(u)
val follows = UserBundledRefresherFlow(u)
val relays = UserBundledRefresherFlow(u)
fun isInUse(): Boolean = relays.stateFlow.subscriptionCount.value > 0 || follows.stateFlow.subscriptionCount.value > 0
fun isInUse(): Boolean =
metadata.stateFlow.subscriptionCount.value > 0 ||
relays.stateFlow.subscriptionCount.value > 0 ||
follows.stateFlow.subscriptionCount.value > 0
fun destroy() {
metadata.destroy()
relays.destroy()
follows.destroy()
}
@@ -488,7 +492,6 @@ class UserLiveSet(
val innerRelayInfo = UserBundledRefresherLiveData(u)
val innerZaps = UserBundledRefresherLiveData(u)
val innerBookmarks = UserBundledRefresherLiveData(u)
val innerGallery = UserBundledRefresherLiveData(u)
val innerStatuses = UserBundledRefresherLiveData(u)
// UI Observers line up here.
@@ -501,7 +504,6 @@ class UserLiveSet(
val relayInfo = innerRelayInfo.map { it }
val zaps = innerZaps.map { it }
val bookmarks = innerBookmarks.map { it }
val gallery = innerGallery.map { it }
val statuses = innerStatuses.map { it }
val profilePictureChanges = innerMetadata.map { it.user.profilePicture() }.distinctUntilChanged()
@@ -520,7 +522,6 @@ class UserLiveSet(
relayInfo.hasObservers() ||
zaps.hasObservers() ||
bookmarks.hasObservers() ||
gallery.hasObservers() ||
statuses.hasObservers() ||
profilePictureChanges.hasObservers() ||
nip05Changes.hasObservers() ||
@@ -536,7 +537,6 @@ class UserLiveSet(
innerRelayInfo.destroy()
innerZaps.destroy()
innerBookmarks.destroy()
innerGallery.destroy()
innerStatuses.destroy()
}
}

View File

@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.ammolite.relays.NostrDataSource
import com.vitorpamplona.ammolite.relays.Relay
@@ -28,7 +29,7 @@ import com.vitorpamplona.quartz.events.Event
abstract class AmethystNostrDataSource(
debugName: String,
) : NostrDataSource(debugName) {
) : NostrDataSource(Amethyst.instance.client, debugName) {
override fun consume(
event: Event,
relay: Relay,

View File

@@ -0,0 +1,103 @@
/**
* 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
import android.content.Context
import android.graphics.BitmapFactory
import androidx.compose.runtime.Stable
import coil3.ImageLoader
import coil3.Uri
import coil3.asImage
import coil3.decode.DataSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.key.Keyer
import coil3.request.ImageRequest
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.base64contentPattern
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import java.util.Base64
@Stable
class Base64Fetcher(
private val options: Options,
private val data: Uri,
) : Fetcher {
override suspend fun fetch(): FetchResult {
checkNotInMainThread()
val matcher = base64contentPattern.matcher(data.toString())
if (matcher.find()) {
val base64String = matcher.group(2)
val byteArray = Base64.getDecoder().decode(base64String)
val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) ?: throw Exception("Unable to load base64 $base64String")
return ImageFetchResult(
image = bitmap.asImage(true),
isSampled = false,
dataSource = DataSource.MEMORY,
)
} else {
throw Exception("Unable to load base64 $data")
}
}
object Factory : Fetcher.Factory<Uri> {
override fun create(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher? =
if (data.scheme == "data") {
Base64Fetcher(options, data)
} else {
null
}
}
object BKeyer : Keyer<Uri> {
override fun key(
data: Uri,
options: Options,
): String? =
if (data.scheme == "data") {
CryptoUtils.sha256(data.toString().toByteArray()).toHexKey()
} else {
null
}
}
}
object Base64Requester {
fun imageRequest(
context: Context,
message: String,
): ImageRequest =
ImageRequest
.Builder(context)
.data(message)
.fetcherFactory(Base64Fetcher.Factory)
.build()
}

View File

@@ -20,60 +20,52 @@
*/
package com.vitorpamplona.amethyst.service
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.decode.DataSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.ImageRequest
import coil.request.Options
import com.vitorpamplona.amethyst.commons.preview.BlurHashDecoder
import java.net.URLDecoder
import java.net.URLEncoder
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DataSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.key.Keyer
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder
class Blurhash(
val blurhash: String,
)
@Stable
class BlurHashFetcher(
private val options: Options,
private val data: Uri,
private val data: Blurhash,
) : Fetcher {
override suspend fun fetch(): FetchResult {
checkNotInMainThread()
val hash = URLDecoder.decode(data.toString().removePrefix("bluehash:"), "utf-8")
val hash = data.blurhash
val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Bluehash $data")
val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Blurhash $data")
return DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
return ImageFetchResult(
image = bitmap.asImage(true),
isSampled = false,
dataSource = DataSource.MEMORY,
)
}
object Factory : Fetcher.Factory<Uri> {
object Factory : Fetcher.Factory<Blurhash> {
override fun create(
data: Uri,
data: Blurhash,
options: Options,
imageLoader: ImageLoader,
): Fetcher = BlurHashFetcher(options, data)
}
}
object BlurHashRequester {
fun imageRequest(
context: Context,
message: String,
): ImageRequest {
val encodedMessage = URLEncoder.encode(message, "utf-8")
return ImageRequest
.Builder(context)
.data("bluehash:$encodedMessage")
.fetcherFactory(BlurHashFetcher.Factory)
.build()
object BKeyer : Keyer<Blurhash> {
override fun key(
data: Blurhash,
options: Options,
): String = data.blurhash
}
}

View File

@@ -26,21 +26,33 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.quartz.events.ImmutableListOfLists
object CachedRichTextParser {
val richTextCache = LruCache<String, RichTextViewerState>(50)
private val richTextCache = LruCache<Int, RichTextViewerState>(50)
fun getCached(content: String): RichTextViewerState? = richTextCache[content]
private fun hashCodeCache(
content: String,
tags: ImmutableListOfLists<String>,
callbackUri: String?,
): Int {
var result = content.hashCode()
result = 31 * result + tags.lists.hashCode()
if (callbackUri != null) {
result = 31 * result + callbackUri.hashCode()
}
return result
}
fun parseText(
content: String,
tags: ImmutableListOfLists<String>,
callbackUri: String? = null,
): RichTextViewerState {
val cached = richTextCache[content]
val key = hashCodeCache(content, tags, callbackUri)
val cached = richTextCache[key]
return if (cached != null) {
cached
} else {
val newUrls = RichTextParser().parseText(content, tags, callbackUri)
richTextCache.put(content, newUrls)
richTextCache.put(key, newUrls)
newUrls
}
}

View File

@@ -23,14 +23,22 @@ package com.vitorpamplona.amethyst.service
import android.content.Context
import android.util.LruCache
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.Event
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@@ -42,16 +50,26 @@ data class CashuToken(
val token: String,
val mint: String,
val totalAmount: Long,
val proofs: JsonNode,
val proofs: List<Proof>,
)
@Serializable
@Immutable
class Proof(
val amount: Int,
val id: String,
val secret: String,
val C: String,
)
object CachedCashuProcessor {
val cashuCache = LruCache<String, GenericLoadable<CashuToken>>(20)
val cashuCache = LruCache<String, GenericLoadable<ImmutableList<CashuToken>>>(20)
fun cached(token: String): GenericLoadable<CashuToken> = cashuCache[token] ?: GenericLoadable.Loading()
fun cached(token: String): GenericLoadable<ImmutableList<CashuToken>> = cashuCache[token] ?: GenericLoadable.Loading()
fun parse(token: String): GenericLoadable<CashuToken> {
fun parse(token: String): GenericLoadable<ImmutableList<CashuToken>> {
if (cashuCache[token] !is GenericLoadable.Loaded) {
checkNotInMainThread()
val newCachuData = CashuProcessor().parse(token)
cashuCache.put(token, newCachuData)
@@ -62,31 +80,145 @@ object CachedCashuProcessor {
}
class CashuProcessor {
fun parse(cashuToken: String): GenericLoadable<CashuToken> {
@Serializable
class V3Token(
val unit: String?, // unit
val memo: String?, // memo
val token: List<V3T>?,
)
@Serializable
class V3T(
val mint: String,
val proofs: List<Proof>,
)
fun parse(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
checkNotInMainThread()
if (cashuToken.startsWith("cashuA")) {
return parseCashuA(cashuToken)
}
if (cashuToken.startsWith("cashuB")) {
return parseCashuB(cashuToken)
}
return GenericLoadable.Error("Could not parse this cashu token")
}
fun parseCashuA(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
checkNotInMainThread()
try {
val base64token = cashuToken.replace("cashuA", "")
val cashu = jacksonObjectMapper().readTree(String(Base64.getDecoder().decode(base64token)))
val token = cashu.get("token").get(0)
val proofs = token.get("proofs")
val mint = token.get("mint").asText()
val cashu = jacksonObjectMapper().readValue<V3Token>(String(Base64.getDecoder().decode(base64token)))
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.get("amount").asLong()
if (cashu.token == null) {
return GenericLoadable.Error("No token found")
}
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs))
val converted =
cashu.token.map { token ->
val proofs = token.proofs
val mint = token.mint
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.amount
}
CashuToken(cashuToken, mint, totalAmount, proofs)
}
return GenericLoadable.Loaded(converted.toImmutableList())
} catch (e: Exception) {
if (e is CancellationException) throw e
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
return GenericLoadable.Error("Could not parse this cashu token")
}
}
@Serializable
class V4Token(
val m: String, // mint
val u: String, // unit
val d: String? = null, // memo
val t: Array<V4T>?,
)
@Serializable
class V4T(
@ByteString
val i: ByteArray, // identifier
val p: Array<V4Proof>,
)
@Serializable
class V4Proof(
val a: Int, // amount
val s: String, // secret
@ByteString
val c: ByteArray, // signature
val d: V4DleqProof? = null, // no idea what this is
val w: String? = null, // witness
)
@Serializable
class V4DleqProof(
@ByteString
val e: ByteArray,
@ByteString
val s: ByteArray,
@ByteString
val r: ByteArray,
)
@OptIn(ExperimentalSerializationApi::class)
fun parseCashuB(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
checkNotInMainThread()
try {
val base64token = cashuToken.replace("cashuB", "")
val parser = Cbor { ignoreUnknownKeys = true }
val v4Token = parser.decodeFromByteArray<V4Token>(Base64.getUrlDecoder().decode(base64token))
val v4proofs = v4Token.t ?: return GenericLoadable.Error("No token found")
val converted =
v4proofs.map { id ->
val proofs =
id.p.map {
Proof(
it.a,
id.i.toHexKey(),
it.s,
it.c.toHexKey(),
)
}
val mint = v4Token.m
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.amount
}
CashuToken(cashuToken, mint, totalAmount, proofs)
}
return GenericLoadable.Loaded(converted.toImmutableList())
} catch (e: Exception) {
e.printStackTrace()
if (e is CancellationException) throw e
return GenericLoadable.Error("Could not parse this cashu token")
}
}
suspend fun melt(
token: CashuToken,
lud16: String,
forceProxy: (url: String) -> Boolean,
onSuccess: (String, String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
@@ -100,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(
@@ -111,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,
@@ -133,6 +268,7 @@ class CashuProcessor {
fun feeCalculator(
mintAddress: String,
invoice: String,
forceProxy: (String) -> Boolean,
onSuccess: (Int) -> Unit,
onError: (String, String) -> Unit,
context: Context,
@@ -140,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
@@ -198,18 +334,33 @@ 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
val jsonObject = factory.objectNode()
jsonObject.put("proofs", token.proofs)
jsonObject.replace(
"proofs",
factory.arrayNode(token.proofs.size).apply {
token.proofs.forEach {
addObject().apply {
put("amount", it.amount)
put("id", it.id)
put("secret", it.secret)
put("C", it.C)
}
}
},
)
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()

View File

@@ -0,0 +1,239 @@
/**
* 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
import android.annotation.SuppressLint
import android.content.Context
import android.location.Geocoder
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Looper
import android.util.Log
import android.util.LruCache
import com.fonfon.kgeohash.GeoHash
import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.service.LocationState.Companion.MIN_DISTANCE
import com.vitorpamplona.amethyst.service.LocationState.Companion.MIN_TIME
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
class LocationFlow(
private val context: Context,
) {
@SuppressLint("MissingPermission")
fun get(
minTimeMs: Long = MIN_TIME,
minDistanceM: Float = MIN_DISTANCE,
): Flow<Location> =
callbackFlow {
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val locationCallback =
object : LocationListener {
override fun onLocationChanged(location: Location) {
launch { send(location) }
}
override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {}
}
Log.d("Location Service", "LocationState Start")
locationManager.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER,
minTimeMs,
minDistanceM,
locationCallback,
Looper.getMainLooper(),
)
awaitClose {
locationManager.removeUpdates(locationCallback)
Log.d("Location Service", "LocationState Stop")
}
}
}
class LocationState(
context: Context,
scope: CoroutineScope,
) {
companion object {
const val MIN_TIME: Long = 10000L
const val MIN_DISTANCE: Float = 100.0f
}
sealed class LocationResult {
data class Success(
val geoHash: GeoHash,
) : LocationResult()
object LackPermission : LocationResult()
object Loading : LocationResult()
}
private var hasLocationPermission = MutableStateFlow<Boolean>(false)
private var latestLocation: LocationResult = LocationResult.Loading
fun setLocationPermission(newValue: Boolean) {
if (newValue != hasLocationPermission.value) {
hasLocationPermission.tryEmit(newValue)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
val geohashStateFlow =
hasLocationPermission
.transformLatest {
emitAll(
LocationFlow
(context)
.get(MIN_TIME, MIN_DISTANCE)
.map {
LocationResult.Success(it.toGeoHash(com.vitorpamplona.amethyst.ui.actions.GeohashPrecision.KM_5_X_5.digits)) as LocationResult
}.onEach {
latestLocation = it
}.catch { e ->
e.printStackTrace()
latestLocation = LocationResult.LackPermission
emit(LocationResult.LackPermission)
},
)
}.stateIn(
scope,
SharingStarted.WhileSubscribed(5000),
latestLocation,
)
}
object CachedGeoLocations {
val locationNames = LruCache<String, String>(20)
fun cached(geoHashStr: String): String? = locationNames[geoHashStr]
suspend fun geoLocate(
geoHashStr: String,
location: Location,
context: Context,
): String? {
locationNames[geoHashStr]?.let {
return it
}
val name = ReverseGeoLocationUtil().execute(location, context)?.ifBlank { null }
if (name != null) {
locationNames.put(geoHashStr, name)
}
return name
}
}
private class ReverseGeoLocationUtil {
suspend fun execute(
location: Location,
context: Context,
): String? {
return try {
Geocoder(context)
.getFromLocation(
location.latitude,
location.longitude,
1,
)?.firstOrNull()
?.let { address ->
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode)
.joinToString(", ")
}
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
return null
}
}
}
class ReverseGeoLocationFlow(
private val context: Context,
) {
@SuppressLint("MissingPermission")
fun get(location: Location): Flow<String?> =
callbackFlow {
val locationManager = Geocoder(context)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val locationCallback =
(
Geocoder.GeocodeListener { addresses ->
launch {
send(
addresses.firstOrNull()?.let {
listOfNotNull(it.locality ?: it.subAdminArea, it.countryCode).joinToString(", ")
},
)
}
}
)
Log.d("GeoLocation Service", "LocationState Start")
locationManager
.getFromLocation(
location.latitude,
location.longitude,
1,
locationCallback,
)
} else {
launch {
send(
Geocoder(context)
.getFromLocation(
location.latitude,
location.longitude,
1,
)?.firstOrNull()
?.let { address ->
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode)
.joinToString(", ")
},
)
}
}
}
}

View File

@@ -1,141 +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
import android.annotation.SuppressLint
import android.content.Context
import android.location.Geocoder
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.HandlerThread
import android.util.LruCache
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
class LocationUtil(
context: Context,
) {
companion object {
const val MIN_TIME: Long = 1000L
const val MIN_DISTANCE: Float = 0.0f
}
private val locationManager =
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private var locationListener: LocationListener? = null
val locationStateFlow = MutableStateFlow<Location>(Location(LocationManager.NETWORK_PROVIDER))
val providerState = mutableStateOf(false)
val isStart: MutableState<Boolean> = mutableStateOf(false)
private val locHandlerThread = HandlerThread("LocationUtil Thread")
init {
locHandlerThread.start()
}
@SuppressLint("MissingPermission")
fun start(
minTimeMs: Long = MIN_TIME,
minDistanceM: Float = MIN_DISTANCE,
) {
locationListener().let {
locationListener = it
locationManager.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER,
minTimeMs,
minDistanceM,
it,
locHandlerThread.looper,
)
}
providerState.value = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
isStart.value = true
}
fun stop() {
locationListener?.let { locationManager.removeUpdates(it) }
isStart.value = false
}
private fun locationListener() =
object : LocationListener {
override fun onLocationChanged(location: Location) {
locationStateFlow.value = location
}
override fun onProviderEnabled(provider: String) {
providerState.value = true
}
override fun onProviderDisabled(provider: String) {
providerState.value = false
}
}
}
object CachedGeoLocations {
val locationNames = LruCache<String, String>(20)
fun cached(geoHashStr: String): String? = locationNames[geoHashStr]
suspend fun geoLocate(
geoHashStr: String,
location: Location,
context: Context,
): String? {
locationNames[geoHashStr]?.let {
return it
}
val name = ReverseGeoLocationUtil().execute(location, context)?.ifBlank { null }
if (name != null) {
locationNames.put(geoHashStr, name)
}
return name
}
}
private class ReverseGeoLocationUtil {
suspend fun execute(
location: Location,
context: Context,
): String? {
return try {
Geocoder(context)
.getFromLocation(location.latitude, location.longitude, 1)
?.firstOrNull()
?.let { address ->
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode)
.joinToString(", ")
}
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
return null
}
}
}

View File

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

View File

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

View File

@@ -25,22 +25,24 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Client
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AppSpecificDataEvent
import com.vitorpamplona.quartz.events.BadgeAwardEvent
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
import com.vitorpamplona.quartz.events.BlossomServersEvent
import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.CalendarDateSlotEvent
import com.vitorpamplona.quartz.events.CalendarRSVPEvent
import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
@@ -53,6 +55,8 @@ import com.vitorpamplona.quartz.events.GitIssueEvent
import com.vitorpamplona.quartz.events.GitPatchEvent
import com.vitorpamplona.quartz.events.GitReplyEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.events.MetadataEvent
@@ -77,44 +81,25 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
val latestEOSEs = EOSEAccount()
val hasLoadedTheBasics = mutableMapOf<User, Boolean>()
fun createAccountContactListFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
kinds = listOf(ContactListEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1,
),
)
fun createAccountMetadataFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1,
),
)
fun createAccountRelayListFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
ContactListEvent.KIND,
StatusEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
SearchRelayListEvent.KIND,
FileServersEvent.KIND,
BlossomServersEvent.KIND,
PrivateOutboxRelayListEvent.KIND,
),
authors = listOf(account.userProfile().pubkeyHex),
limit = 10,
limit = 20,
),
)
@@ -124,7 +109,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
return TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
@@ -133,11 +118,12 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
ChatMessageRelayListEvent.KIND,
SearchRelayListEvent.KIND,
FileServersEvent.KIND,
BlossomServersEvent.KIND,
MuteListEvent.KIND,
PeopleListEvent.KIND,
),
authors = otherAuthors,
limit = 100,
limit = otherAuthors.size * 20,
),
)
}
@@ -146,24 +132,36 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND, BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 100,
),
)
fun createAccountSettings2Filter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(AppSpecificDataEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
tags = mapOf("d" to listOf(Account.APP_SPECIFIC_DATA_D_TAG)),
limit = 1,
),
)
fun createAccountReportsFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(DraftEvent.KIND, ReportEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultNotificationFollowList.value)
?.get(account.settings.defaultNotificationFollowList.value)
?.relayList,
),
)
@@ -172,24 +170,38 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
authors = listOf(account.userProfile().pubkeyHex),
limit = 400,
),
)
fun createNotificationFilter(): TypedFilter {
val since =
var since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultNotificationFollowList.value)
?.get(account.settings.defaultNotificationFollowList.value)
?.relayList
?: account.connectToRelays.value.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
?.toMutableMap()
if (since == null) {
since =
account.connectToRelays.value
.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
.toMutableMap()
} else {
account.connectToRelays.value.forEach {
val eose = since.get(it.url)
if (eose == null) {
since.put(it.url, EOSETime(TimeUtils.oneWeekAgo()))
}
}
}
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -214,7 +226,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
val since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultNotificationFollowList.value)
?.get(account.settings.defaultNotificationFollowList.value)
?.relayList
?: account.connectToRelays.value.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
@@ -222,16 +234,19 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
GitReplyEvent.KIND,
GitIssueEvent.KIND,
GitPatchEvent.KIND,
HighlightEvent.KIND,
CommentEvent.KIND,
CalendarDateSlotEvent.KIND,
CalendarTimeSlotEvent.KIND,
CalendarRSVPEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
InteractiveStorySceneEvent.KIND,
),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 400,
@@ -244,7 +259,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(GiftWrapEvent.KIND),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since =
@@ -263,7 +278,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
if (hasLoadedTheBasics[account.userProfile()] != null) {
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultNotificationFollowList.value,
account.settings.defaultNotificationFollowList.value,
relayUrl,
time,
)
@@ -284,99 +299,121 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
override fun consume(
event: Event,
relay: Relay,
) {
if (LocalCache.justVerify(event)) {
consumeAlreadyVerified(event, relay)
}
}
fun consumeAlreadyVerified(
event: Event,
relay: Relay,
) {
checkNotInMainThread()
if (LocalCache.justVerify(event)) {
when (event) {
is PrivateOutboxRelayListEvent -> {
when (event) {
is PrivateOutboxRelayListEvent -> {
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
val noteEvent = note?.event
if (noteEvent == null || event.createdAt > noteEvent.createdAt()) {
event.privateTags(account.signer) {
LocalCache.justConsume(event, relay)
}
}
}
is DraftEvent -> {
// Avoid decrypting over and over again if the event already exist.
if (!event.isDeleted()) {
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
val noteEvent = note?.event
if (noteEvent == null || event.createdAt > noteEvent.createdAt()) {
event.privateTags(account.signer) {
LocalCache.justConsume(event, relay)
}
}
}
is DraftEvent -> {
// Avoid decrypting over and over again if the event already exist.
if (!event.isDeleted()) {
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
val noteEvent = note?.event
if (noteEvent != null) {
if (event.createdAt > noteEvent.createdAt() || relay.brief !in note.relays) {
LocalCache.consume(event, relay)
}
} else {
// decrypts
event.cachedDraft(account.signer) {}
LocalCache.justConsume(event, relay)
}
}
}
is GiftWrapEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
val noteEvent = note?.event as? GiftWrapEvent
if (noteEvent != null) {
if (relay.brief !in note.relays) {
noteEvent.cachedGift(account.signer) {
LocalCache.justConsume(noteEvent, relay)
this.consume(it, relay)
}
if (event.createdAt > noteEvent.createdAt() || relay.brief !in note.relays) {
LocalCache.consume(event, relay)
}
} else {
// new event
event.cachedGift(account.signer) {
LocalCache.justConsume(event, relay)
this.consume(it, relay)
}
}
}
is SealedGossipEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
val noteEvent = note?.event as? SealedGossipEvent
if (noteEvent != null) {
if (relay.brief !in note.relays) {
// adds the relay to seal and inner chat
noteEvent.cachedGossip(account.signer) {
LocalCache.consume(noteEvent, relay)
LocalCache.justConsume(it, relay)
}
}
} else {
// new event
event.cachedGossip(account.signer) {
LocalCache.justConsume(event, relay)
LocalCache.justConsume(it, relay)
}
}
}
is LnZapEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note?.event == null) {
event.zapRequest?.let {
if (it.isPrivateZap()) {
it.decryptPrivateZap(account.signer) {}
}
}
// decrypts
event.cachedDraft(account.signer) {}
LocalCache.justConsume(event, relay)
}
}
}
is GiftWrapEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
val noteEvent = note?.event as? GiftWrapEvent
if (noteEvent != null) {
if (relay.brief !in note.relays) {
LocalCache.justConsume(noteEvent, relay)
noteEvent.innerEventId?.let {
(LocalCache.getNoteIfExists(it)?.event as? Event)?.let {
this.consumeAlreadyVerified(it, relay)
}
} ?: run {
event.unwrap(account.signer) {
this.consume(it, relay)
noteEvent.innerEventId = it.id
}
}
}
} else {
// new event
event.unwrap(account.signer) {
LocalCache.justConsume(event, relay)
this.consume(it, relay)
}
}
}
is SealedGossipEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
val noteEvent = note?.event as? SealedGossipEvent
if (noteEvent != null) {
if (relay.brief !in note.relays) {
LocalCache.justConsume(noteEvent, relay)
noteEvent.innerEventId?.let {
(LocalCache.getNoteIfExists(it)?.event as? Event)?.let {
LocalCache.justConsume(it, relay)
}
} ?: run {
event.unseal(account.signer) {
LocalCache.justConsume(it, relay)
noteEvent.innerEventId = it.id
}
}
}
} else {
// new event
event.unseal(account.signer) {
LocalCache.justConsume(event, relay)
LocalCache.justConsume(it, relay)
}
}
}
is LnZapEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note?.event == null) {
event.zapRequest?.let {
if (it.isPrivateZap()) {
it.decryptPrivateZap(account.signer) {}
}
}
else -> {
LocalCache.justConsume(event, relay)
}
}
else -> {
LocalCache.justConsume(event, relay)
}
}
}
@@ -394,16 +431,30 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
}
private fun markInnerAsSeenOnRelay(
noteEvent: EventInterface,
newNoteEvent: EventInterface,
relay: Relay,
) {
LocalCache.getNoteIfExists(noteEvent.id())?.addRelay(relay)
markInnerAsSeenOnRelay(newNoteEvent.id(), relay)
}
if (noteEvent is GiftWrapEvent) {
noteEvent.cachedGift(account.signer) { gift -> markInnerAsSeenOnRelay(gift, relay) }
} else if (noteEvent is SealedGossipEvent) {
noteEvent.cachedGossip(account.signer) { rumor ->
markInnerAsSeenOnRelay(rumor, relay)
private fun markInnerAsSeenOnRelay(
eventId: HexKey,
relay: Relay,
) {
val note = LocalCache.getNoteIfExists(eventId)
if (note != null) {
note.addRelay(relay)
val noteEvent = note.event
if (noteEvent is GiftWrapEvent) {
noteEvent.innerEventId?.let {
markInnerAsSeenOnRelay(it, relay)
}
} else if (noteEvent is SealedGossipEvent) {
noteEvent.innerEventId?.let {
markInnerAsSeenOnRelay(it, relay)
}
}
}
}
@@ -414,8 +465,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
accountChannel.typedFilters =
listOfNotNull(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
createAccountSettings2Filter(),
createNotificationFilter(),
createNotificationFilter2(),
createGiftWrapsToMeFilter(),
@@ -429,9 +479,8 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
accountChannel.typedFilters =
listOf(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
createAccountSettingsFilter(),
createAccountSettings2Filter(),
).ifEmpty { null }
}
@@ -442,12 +491,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
super.auth(relay, challenge)
if (this::account.isInitialized) {
account.createAuthEvent(relay, challenge) {
Client.send(
it,
relay.url,
)
}
account.sendAuthEvent(relay, challenge)
}
}

View File

@@ -25,8 +25,8 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
@@ -58,7 +58,7 @@ object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50,
@@ -71,7 +71,7 @@ object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50,
@@ -86,7 +86,7 @@ object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
limit = 200,
@@ -96,7 +96,7 @@ object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
tags = mapOf("a" to listOfNotNull(channel?.idHex)),
limit = 200,

View File

@@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.PrivateDmEvent
@@ -50,7 +50,7 @@ object NostrChatroomDataSource : AmethystNostrDataSource("ChatroomFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = myPeer.users.toList(),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
@@ -73,7 +73,7 @@ object NostrChatroomDataSource : AmethystNostrDataSource("ChatroomFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
tags = mapOf("p" to myPeer.users.map { it }),

View File

@@ -24,8 +24,8 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
@@ -41,7 +41,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since =
@@ -56,7 +56,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
@@ -71,7 +71,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
@@ -91,7 +91,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
// Metadata comes from any relay
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelCreateEvent.KIND),
ids = followingEvents.toList(),
since =
@@ -113,7 +113,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
// Metadata comes from any relay
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelMetadataEvent.KIND),
tags = mapOf("e" to listOf(it)),
limit = 1,
@@ -131,7 +131,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOf(it)),
since =

View File

@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
@@ -35,15 +35,19 @@ object NostrCommunityDataSource : AmethystNostrDataSource("SingleCommunityFeed")
val community = myCommunityToWatch.event as? CommunityDefinitionEvent ?: return null
val authors =
community
.moderators()
.map { it.key }
.plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex))
if (authors.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
authors =
community
.moderators()
.map { it.key }
.plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)),
SincePerRelayFilter(
authors = authors,
tags =
mapOf(
"a" to listOf(myCommunityToWatch.address.toTag()),

View File

@@ -24,8 +24,9 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AppDefinitionEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
@@ -66,31 +67,30 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
}
fun createMarketplaceFilter(): List<TypedFilter> {
val follows =
account.liveDiscoveryFollowLists.value
?.users
?.toList()
val follows = account.liveDiscoveryListAuthorsPerRelay.value?.ifEmpty { null }
val hashToLoad =
account.liveDiscoveryFollowLists.value
?.hashtags
?.toList()
?.ifEmpty { null }
val geohashToLoad =
account.liveDiscoveryFollowLists.value
?.geotags
?.toList()
?.ifEmpty { null }
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
filter =
Filter(
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(ClassifiedsEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
),
@@ -98,7 +98,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ClassifiedsEvent.KIND),
tags =
mapOf(
@@ -111,7 +111,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -120,20 +120,17 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ClassifiedsEvent.KIND),
tags =
mapOf(
"g" to
it
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
"g" to it,
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -146,14 +143,14 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(AppDefinitionEvent.KIND),
limit = 300,
tags = mapOf("k" to kTags),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
),
@@ -162,36 +159,39 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
fun createLiveStreamFilter(): List<TypedFilter> {
val follows =
account.liveDiscoveryFollowLists.value
?.users
?.authors
?.toList()
?.ifEmpty { null }
val followsRelays = account.liveDiscoveryListAuthorsPerRelay.value
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
filter =
Filter(
authors = follows,
SinceAuthorPerRelayFilter(
authors = followsRelays,
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
),
follows?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
types = setOf(FeedType.FOLLOWS),
filter =
Filter(
SincePerRelayFilter(
tags = mapOf("p" to it),
kinds = listOf(LiveActivitiesEvent.KIND),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -200,24 +200,21 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
}
fun createPublicChatFilter(): List<TypedFilter> {
val follows =
account.liveDiscoveryFollowLists.value
?.users
?.toList()
val follows = account.liveDiscoveryListAuthorsPerRelay.value?.ifEmpty { null }
val followChats = account.selectedChatsFollowList().toList()
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(ChannelMessageEvent.KIND),
limit = 500,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
),
@@ -225,14 +222,14 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
ids = followChats,
kinds = listOf(ChannelCreateEvent.KIND, ChannelMessageEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -243,22 +240,19 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
}
fun createCommunitiesFilter(): TypedFilter {
val follows =
account.liveDiscoveryFollowLists.value
?.users
?.toList()
val follows = account.liveDiscoveryListAuthorsPerRelay.value
return TypedFilter(
types = setOf(FeedType.GLOBAL),
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
filter =
Filter(
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -275,7 +269,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
tags =
mapOf(
@@ -288,7 +282,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -305,20 +299,14 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
tags = mapOf("g" to hashToLoad),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -335,7 +323,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
tags =
@@ -349,7 +337,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -366,21 +354,16 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
mapOf("g" to hashToLoad),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -397,7 +380,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
tags =
mapOf(
@@ -410,7 +393,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -427,20 +410,14 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
tags = mapOf("g" to hashToLoad),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.get(account.settings.defaultDiscoveryFollowList.value)
?.relayList,
),
)
@@ -450,7 +427,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultDiscoveryFollowList.value,
account.settings.defaultDiscoveryFollowList.value,
relayUrl,
time,
)

View File

@@ -21,14 +21,14 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
@@ -43,7 +43,7 @@ object NostrGeohashDataSource : AmethystNostrDataSource("SingleGeoHashFeed") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
tags =
mapOf(
"g" to
@@ -57,12 +57,12 @@ object NostrGeohashDataSource : AmethystNostrDataSource("SingleGeoHashFeed") {
ChannelMessageEvent.KIND,
LongTextNoteEvent.KIND,
PollNoteEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
WikiNoteEvent.KIND,
CommentEvent.KIND,
),
limit = 200,
),

View File

@@ -21,13 +21,15 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
@@ -37,45 +39,59 @@ import com.vitorpamplona.quartz.events.WikiNoteEvent
object NostrHashtagDataSource : AmethystNostrDataSource("SingleHashtagFeed") {
private var hashtagToWatch: String? = null
fun createLoadHashtagFilter(): TypedFilter? {
val hashToLoad = hashtagToWatch ?: return null
fun createLoadHashtagFilter(): List<TypedFilter> {
val hashToLoad = hashtagToWatch ?: return emptyList()
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
tags =
mapOf(
"t" to
listOf(
hashToLoad,
hashToLoad.lowercase(),
hashToLoad.uppercase(),
hashToLoad.capitalize(),
),
),
kinds =
listOf(
TextNoteEvent.KIND,
ChannelMessageEvent.KIND,
LongTextNoteEvent.KIND,
PollNoteEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
WikiNoteEvent.KIND,
),
limit = 200,
),
val hashtagsToFollow =
listOf(
hashToLoad,
hashToLoad.lowercase(),
hashToLoad.uppercase(),
hashToLoad.capitalize(),
)
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
tags = mapOf("t" to hashtagsToFollow),
kinds =
listOf(
TextNoteEvent.KIND,
ChannelMessageEvent.KIND,
LongTextNoteEvent.KIND,
PollNoteEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
WikiNoteEvent.KIND,
CommentEvent.KIND,
),
limit = 200,
),
),
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
tags = mapOf("t" to hashtagsToFollow),
kinds =
listOf(
InteractiveStorySceneEvent.KIND,
),
limit = 200,
),
),
)
}
val loadHashtagChannel = requestNewChannel()
override fun updateChannelFilters() {
loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null }
loadHashtagChannel.typedFilters = createLoadHashtagFilter().ifEmpty { null }
}
fun loadHashtag(tag: String?) {

View File

@@ -24,15 +24,18 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
@@ -45,7 +48,6 @@ import com.vitorpamplona.quartz.events.WikiNoteEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
lateinit var account: Account
@@ -54,81 +56,109 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
val latestEOSEs = EOSEAccount()
var job: Job? = null
var job2: Job? = null
override fun start() {
job?.cancel()
job =
scope.launch(Dispatchers.IO) {
// creates cache on main
withContext(Dispatchers.Main) { account.userProfile().live() }
account.liveHomeFollowLists.collect {
if (this@NostrHomeDataSource::account.isInitialized) {
invalidateFilters()
}
}
}
job2?.cancel()
job2 =
scope.launch(Dispatchers.IO) {
account.liveHomeListAuthorsPerRelay.collect {
if (this@NostrHomeDataSource::account.isInitialized) {
invalidateFilters()
}
}
}
super.start()
}
override fun stop() {
super.stop()
job?.cancel()
job2?.cancel()
}
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.liveHomeFollowLists.value?.users
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null }
fun createFollowAccountsFilter(): List<TypedFilter> {
val follows =
account.liveHomeListAuthorsPerRelay.value
return TypedFilter(
types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS),
filter =
Filter(
kinds =
listOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ClassifiedsEvent.KIND,
LongTextNoteEvent.KIND,
PollNoteEvent.KIND,
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
PinListEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesEvent.KIND,
WikiNoteEvent.KIND,
),
authors = followSet,
limit = 400,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.relayList,
),
return listOf(
TypedFilter(
types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ClassifiedsEvent.KIND,
LongTextNoteEvent.KIND,
PollNoteEvent.KIND,
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
PinListEvent.KIND,
),
authors = follows,
limit = 400,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultHomeFollowList.value)
?.relayList,
),
),
TypedFilter(
types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
kinds =
listOf(
InteractiveStoryPrologueEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesEvent.KIND,
WikiNoteEvent.KIND,
),
authors = follows,
limit = 400,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultHomeFollowList.value)
?.relayList,
),
),
)
}
fun createFollowMetadataAndReleaseFilter(): TypedFilter? {
val follows = account.liveHomeFollowLists.value?.users
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.shuffled()?.ifEmpty { null }
val follows = account.liveHomeListAuthorsPerRelay.value
return if (followSet != null) {
return if (!follows.isNullOrEmpty()) {
TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
Filter(
SinceAuthorPerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
AdvertisedRelayListEvent.KIND,
),
authors = followSet.take(500),
authors = follows,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.get(account.settings.defaultHomeFollowList.value)
?.relayList,
),
)
@@ -145,7 +175,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -155,8 +185,8 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
CommentEvent.KIND,
WikiNoteEvent.KIND,
),
tags =
@@ -170,7 +200,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.get(account.settings.defaultHomeFollowList.value)
?.relayList,
),
)
@@ -184,7 +214,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -193,48 +223,43 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
WikiNoteEvent.KIND,
CommentEvent.KIND,
),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
"g" to hashToLoad.toList(),
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.get(account.settings.defaultHomeFollowList.value)
?.relayList,
),
)
}
fun createFollowCommunitiesFilter(): TypedFilter? {
val communitiesToLoad = account.liveHomeFollowLists.value?.communities ?: return null
val communitiesToLoad = account.liveHomeFollowLists.value?.addresses ?: return null
if (communitiesToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
WikiNoteEvent.KIND,
CommunityPostApprovalEvent.KIND,
CommentEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
),
tags =
mapOf(
@@ -244,7 +269,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.get(account.settings.defaultHomeFollowList.value)
?.relayList,
),
)
@@ -254,7 +279,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultHomeFollowList.value,
account.settings.defaultHomeFollowList.value,
relayUrl,
time,
)
@@ -262,12 +287,14 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
override fun updateChannelFilters() {
followAccountChannel.typedFilters =
listOfNotNull(
createFollowAccountsFilter(),
createFollowMetadataAndReleaseFilter(),
createFollowCommunitiesFilter(),
createFollowTagsFilter(),
createFollowGeohashesFilter(),
(
createFollowAccountsFilter() +
listOfNotNull(
createFollowMetadataAndReleaseFilter(),
createFollowCommunitiesFilter(),
createFollowTagsFilter(),
createFollowGeohashesFilter(),
)
).ifEmpty { null }
}
}

View File

@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.signers.NostrSigner
@@ -39,7 +39,7 @@ class NostrLnZapPaymentResponseDataSource(
return TypedFilter(
types = feedTypes,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LnZapPaymentResponseEvent.KIND),
authors = listOf(fromServiceHex),
tags =

View File

@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.ALL_FEED_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
@@ -38,9 +38,12 @@ import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.EmojiPackEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.MetadataEvent
@@ -93,9 +96,9 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
listOfNotNull(
ATag.parse(it, null)?.let { aTag ->
TypedFilter(
types = COMMON_FEED_TYPES,
types = ALL_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND, aTag.kind),
authors = listOfNotNull(aTag.pubKeyHex),
// just to be sure
@@ -108,19 +111,19 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
// event ids
listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
types = ALL_FEED_TYPES,
filter =
Filter(
ids = listOfNotNull(hexToWatch),
SincePerRelayFilter(
ids = listOfNotNull(it),
),
),
// authors
TypedFilter(
types = COMMON_FEED_TYPES,
types = ALL_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOfNotNull(hexToWatch),
authors = listOfNotNull(it),
// just to be sure
limit = 5,
),
@@ -135,7 +138,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
search = mySearchString,
limit = 1000,
@@ -144,7 +147,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -165,7 +168,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
ChannelMetadataEvent.KIND,
@@ -177,6 +180,20 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
PollNoteEvent.KIND,
NNSEvent.KIND,
WikiNoteEvent.KIND,
CommentEvent.KIND,
),
search = mySearchString,
limit = 100,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds =
listOf(
InteractiveStoryPrologueEvent.KIND,
InteractiveStorySceneEvent.KIND,
),
search = mySearchString,
limit = 100,

View File

@@ -25,8 +25,8 @@ import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
@@ -44,7 +44,7 @@ object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelMetadataEvent.KIND),
tags = mapOf("e" to reactionsToWatch),
),
@@ -65,7 +65,7 @@ object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed
return TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelCreateEvent.KIND),
ids = interestedEvents.toList(),
),
@@ -88,7 +88,7 @@ object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),

View File

@@ -23,10 +23,10 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.DeletionEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
@@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
import com.vitorpamplona.quartz.events.TorrentCommentEvent
object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
private var nextEventsToWatch = setOf<Note>()
@@ -67,7 +68,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -89,7 +90,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
DeletionEvent.KIND,
@@ -117,7 +118,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(aTag.kind),
authors = listOf(aTag.pubKeyHex),
limit = 5,
@@ -127,7 +128,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),
@@ -150,7 +151,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -173,12 +174,13 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
DeletionEvent.KIND,
NIP90ContentDiscoveryResponseEvent.KIND,
NIP90StatusEvent.KIND,
TorrentCommentEvent.KIND,
),
tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
@@ -200,7 +202,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(TextNoteEvent.KIND),
tags = mapOf("q" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
@@ -232,7 +234,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
ids = interestedEvents.toList(),
),
),
@@ -319,6 +321,9 @@ fun groupByEOSEPresence(notes: Set<Note>): Collection<List<Note>> =
.sorted()
.joinToString(",")
}.values
.map {
it.sortedBy { it.idHex } // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again
}
fun groupByEOSEPresence(users: Iterable<User>): Collection<List<User>> =
users
@@ -327,6 +332,9 @@ fun groupByEOSEPresence(users: Iterable<User>): Collection<List<User>> =
.sorted()
.joinToString(",")
}.values
.map {
it.sortedBy { it.pubkeyHex } // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again
}
fun findMinimumEOSEs(notes: List<Note>): Map<String, EOSETime> {
val minLatestEOSEs = mutableMapOf<String, EOSETime>()

View File

@@ -21,13 +21,14 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.RelationshipStatusEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.StatusEvent
@@ -45,7 +46,7 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND),
authors = firstTimers,
),
@@ -64,26 +65,31 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") {
.map { group ->
val groupIds = group.map { it.pubkeyHex }
val minEOSEs = findMinimumEOSEsForUsers(group)
listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND),
authors = groupIds,
since = minEOSEs,
),
),
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
kinds = listOf(ReportEvent.KIND),
tags = mapOf("p" to groupIds),
since = minEOSEs,
),
),
)
if (groupIds.isNotEmpty()) {
listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND, RelationshipStatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND),
authors = groupIds,
since = minEOSEs,
),
),
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(ReportEvent.KIND),
tags = mapOf("p" to groupIds),
since = minEOSEs,
),
),
)
} else {
listOf()
}
}.flatten()
}
@@ -92,13 +98,11 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") {
checkNotInMainThread()
usersToWatch.forEach {
if (it.latestMetadata != null) {
val eose = it.latestEOSEs[relayUrl]
if (eose == null) {
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
val eose = it.latestEOSEs[relayUrl]
if (eose == null) {
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
}

View File

@@ -20,34 +20,64 @@
*/
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") {
private var eventToWatch: String? = null
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val threadToLoad = eventToWatch ?: return null
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter> {
val threadToLoad = eventToWatch ?: return emptyList()
val branch = ThreadAssembler().findThreadFor(threadToLoad) ?: return emptyList()
val eventsToLoad =
ThreadAssembler()
.findThreadFor(threadToLoad)
branch.allNotes
.filter { it.event == null }
.map { it.idHex }
.toSet()
.ifEmpty { null }
?: return null
if (eventsToLoad.isEmpty()) return null
val address = if (branch.root is AddressableNote) branch.root.idHex else null
val event = if (branch.root !is AddressableNote) branch.root.idHex else branch.root.event?.id()
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
ids = eventsToLoad.toList(),
),
return listOfNotNull(
eventsToLoad?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
ids = it.toList(),
),
)
},
event?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
tags =
mapOf(
"e" to listOf(event),
),
),
)
},
address?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
tags =
mapOf(
"a" to listOf(address),
),
),
)
},
)
}
@@ -59,8 +89,7 @@ object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") {
}
override fun updateChannelFilters() {
loadEventsChannel.typedFilters =
listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
loadEventsChannel.typedFilters = createLoadEventsIfNotLoadedFilter()
}
fun loadThread(noteId: String?) {

View File

@@ -22,26 +22,33 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AppRecommendationEvent
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.BadgeAwardEvent
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PictureEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TorrentCommentEvent
import com.vitorpamplona.quartz.events.TorrentEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
import com.vitorpamplona.quartz.events.VideoVerticalEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
@@ -56,7 +63,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
@@ -69,7 +76,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -89,12 +96,31 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
)
}
fun createUserPostsFilter2() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
TorrentEvent.KIND,
TorrentCommentEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
CommentEvent.KIND,
),
authors = listOf(it.pubkeyHex),
limit = 50,
),
)
}
fun createUserReceivedZapsFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LnZapEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
limit = 200,
@@ -107,7 +133,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ContactListEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
@@ -120,7 +146,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ContactListEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
),
@@ -132,7 +158,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(BadgeProfilesEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
@@ -145,7 +171,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, AppRecommendationEvent.KIND),
authors = listOf(it.pubkeyHex),
@@ -159,9 +185,9 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(ProfileGalleryEntryEvent.KIND),
listOf(ProfileGalleryEntryEvent.KIND, PictureEvent.KIND, VideoVerticalEvent.KIND, VideoHorizontalEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1000,
),
@@ -173,7 +199,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(BadgeAwardEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
limit = 20,
@@ -188,6 +214,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
listOfNotNull(
createUserInfoFilter(),
createUserPostsFilter(),
createUserPostsFilter2(),
createProfileGalleryFilter(),
createFollowFilter(),
createFollowersFilter(),

View File

@@ -24,10 +24,12 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.PictureEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
import com.vitorpamplona.quartz.events.VideoVerticalEvent
import kotlinx.coroutines.Dispatchers
@@ -63,88 +65,136 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
job?.cancel()
}
fun createContextualFilter(): TypedFilter {
val follows =
account.liveStoriesFollowLists.value
?.users
?.toList()
fun createContextualFilter(): List<TypedFilter> {
val follows = account.liveStoriesListAuthorsPerRelay.value
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
authors = follows,
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
limit = 200,
tags = mapOf("m" to SUPPORTED_VIDEO_FEED_MIME_TYPES),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultStoriesFollowList.value)
?.relayList,
),
val types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS)
return listOf(
TypedFilter(
types = types,
filter =
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
limit = 200,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
),
TypedFilter(
types = types,
filter =
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
limit = 200,
tags = mapOf("m" to SUPPORTED_VIDEO_FEED_MIME_TYPES),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
),
)
}
fun createFollowTagsFilter(): TypedFilter? {
fun createFollowTagsFilter(): List<TypedFilter> {
val hashToLoad =
account.liveStoriesFollowLists.value
?.hashtags
?.toList() ?: return null
?.toList() ?: return emptyList()
if (hashToLoad.isEmpty()) return null
if (hashToLoad.isEmpty()) return emptyList()
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultStoriesFollowList.value)
?.relayList,
),
val hashtags =
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten()
return listOf(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
tags = mapOf("t" to hashtags),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
),
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
tags =
mapOf(
"t" to hashtags,
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
),
)
}
fun createFollowGeohashesFilter(): TypedFilter? {
fun createFollowGeohashesFilter(): List<TypedFilter> {
val hashToLoad =
account.liveStoriesFollowLists.value
?.geotags
?.toList() ?: return null
?.toList() ?: return emptyList()
if (hashToLoad.isEmpty()) return null
if (hashToLoad.isEmpty()) return emptyList()
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultStoriesFollowList.value)
?.relayList,
),
val geoHashes = hashToLoad
return listOf(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
tags = mapOf("g" to geoHashes),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
),
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
tags =
mapOf(
"g" to geoHashes,
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
),
)
}
@@ -152,7 +202,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultStoriesFollowList.value,
account.settings.defaultStoriesFollowList.value,
relayUrl,
time,
)
@@ -164,6 +214,6 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
createContextualFilter(),
createFollowTagsFilter(),
createFollowGeohashesFilter(),
).ifEmpty { null }
).flatten().ifEmpty { null }
}
}

View File

@@ -24,7 +24,7 @@ import android.util.Log
import android.util.LruCache
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.quartz.crypto.CryptoUtils
import okhttp3.EventListener
import okhttp3.Protocol
@@ -49,7 +49,10 @@ object OnlineChecker {
return false
}
fun isOnline(url: String?): Boolean {
fun isOnline(
url: String?,
forceProxy: Boolean,
): Boolean {
checkNotInMainThread()
if (url.isNullOrBlank()) return false
@@ -74,7 +77,7 @@ object OnlineChecker {
val client =
HttpClientManager
.getHttpClient()
.getHttpClient(forceProxy)
.newBuilder()
.eventListener(EventListener.NONE)
.protocols(listOf(Protocol.HTTP_1_1))
@@ -93,7 +96,7 @@ object OnlineChecker {
.get()
.build()
HttpClientManager.getHttpClient().newCall(request).execute().use {
HttpClientManager.getHttpClient(forceProxy).newCall(request).execute().use {
checkNotInMainThread()
it.isSuccessful
}

View File

@@ -23,12 +23,13 @@ package com.vitorpamplona.amethyst.service
import android.content.Context
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.collectSuccessfulOperations
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
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AppDefinitionEvent
@@ -60,7 +61,8 @@ class ZapPaymentHandler(
message: String,
context: Context,
showErrorIfNoLnAddress: Boolean,
onError: (String, String) -> Unit,
forceProxy: (String) -> Boolean,
onError: (String, String, User?) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
zapType: LnZapEvent.ZapType,
@@ -81,13 +83,16 @@ class ZapPaymentHandler(
val lud16 = note.author?.info?.lnAddress()
if (lud16.isNullOrBlank()) {
onError(
stringRes(context, R.string.missing_lud16),
stringRes(
context,
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
),
)
if (showErrorIfNoLnAddress) {
onError(
stringRes(context, R.string.missing_lud16),
stringRes(
context,
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
),
note.author,
)
}
return@withContext
}
@@ -97,13 +102,16 @@ class ZapPaymentHandler(
val lud16 = note.author?.info?.lnAddress()
if (lud16.isNullOrBlank()) {
onError(
stringRes(context, R.string.missing_lud16),
stringRes(
context,
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
),
)
if (showErrorIfNoLnAddress) {
onError(
stringRes(context, R.string.missing_lud16),
stringRes(
context,
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
),
note.author,
)
}
return@withContext
}
@@ -119,10 +127,10 @@ class ZapPaymentHandler(
onProgress(0.05f)
}
assembleAllInvoices(splitZapRequestPairs.toList(), amountMilliSats, message, showErrorIfNoLnAddress, onError, onProgress = {
assembleAllInvoices(splitZapRequestPairs, amountMilliSats, message, showErrorIfNoLnAddress, forceProxy, onError, onProgress = {
onProgress(it * 0.7f + 0.05f) // keeps within range.
}, context) {
if (it.isEmpty()) {
}, context) { payables ->
if (payables.isEmpty()) {
onProgress(0.00f)
return@assembleAllInvoices
} else {
@@ -130,22 +138,14 @@ class ZapPaymentHandler(
}
if (account.hasWalletConnectSetup()) {
payViaNWC(it.values.map { it.invoice }, note, onError, onProgress = {
payViaNWC(payables, note, onError = onError, onProgress = {
onProgress(it * 0.25f + 0.75f) // keeps within range.
}, context) {
// onProgress(1f)
}
} else {
onPayViaIntent(
it
.map {
Payable(
info = it.key.first,
user = it.key.second.user,
amountMilliSats = it.value.zapValue,
invoice = it.value.invoice,
)
}.toImmutableList(),
payables.toImmutableList(),
)
onProgress(0f)
@@ -164,8 +164,9 @@ class ZapPaymentHandler(
return roundedZapValue
}
class SignAllZapRequestsReturn(
val zapRequestJson: String,
class ZapRequestReady(
val inputSetup: ZapSplitSetup,
val zapRequestJson: String?,
val user: User? = null,
)
@@ -175,7 +176,7 @@ class ZapPaymentHandler(
message: String,
zapType: LnZapEvent.ZapType,
zapsToSend: List<ZapSplitSetup>,
onAllDone: suspend (MutableMap<ZapSplitSetup, SignAllZapRequestsReturn>) -> Unit,
onAllDone: suspend (List<ZapRequestReady>) -> Unit,
) {
val authorRelayList =
note.author
@@ -189,13 +190,13 @@ class ZapPaymentHandler(
)?.readRelays()
}?.toSet()
collectSuccessfulSigningOperations<ZapSplitSetup, SignAllZapRequestsReturn>(
operationsInput = zapsToSend,
collectSuccessfulOperations<ZapSplitSetup, ZapRequestReady>(
items = zapsToSend,
runRequestFor = { next: ZapSplitSetup, onReady ->
if (next.isLnAddress) {
prepareZapRequestIfNeeded(note, pollOption, message, zapType) { zapRequestJson ->
if (zapRequestJson != null) {
onReady(SignAllZapRequestsReturn(zapRequestJson))
onReady(ZapRequestReady(next, zapRequestJson))
}
}
} else {
@@ -211,9 +212,7 @@ class ZapPaymentHandler(
) + (authorRelayList ?: emptySet())
prepareZapRequestIfNeeded(note, pollOption, message, zapType, user, userRelayList) { zapRequestJson ->
if (zapRequestJson != null) {
onReady(SignAllZapRequestsReturn(zapRequestJson, user))
}
onReady(ZapRequestReady(next, zapRequestJson, user))
}
}
},
@@ -222,30 +221,33 @@ class ZapPaymentHandler(
}
suspend fun assembleAllInvoices(
invoices: List<Pair<ZapSplitSetup, SignAllZapRequestsReturn>>,
requests: List<ZapRequestReady>,
totalAmountMilliSats: Long,
message: String,
showErrorIfNoLnAddress: Boolean,
onError: (String, String) -> Unit,
forceProxy: (String) -> Boolean,
onError: (String, String, User?) -> Unit,
onProgress: (percent: Float) -> Unit,
context: Context,
onAllDone: suspend (MutableMap<Pair<ZapSplitSetup, SignAllZapRequestsReturn>, AssembleInvoiceReturn>) -> Unit,
onAllDone: suspend (List<Payable>) -> Unit,
) {
var progressAllPayments = 0.00f
val totalWeight = invoices.sumOf { it.first.weight }
val totalWeight = requests.sumOf { it.inputSetup.weight }
collectSuccessfulSigningOperations<Pair<ZapSplitSetup, SignAllZapRequestsReturn>, AssembleInvoiceReturn>(
operationsInput = invoices,
runRequestFor = { splitZapRequestPair: Pair<ZapSplitSetup, SignAllZapRequestsReturn>, onReady ->
collectSuccessfulOperations<ZapRequestReady, Payable>(
items = requests,
runRequestFor = { splitZapRequestPair: ZapRequestReady, onReady ->
assembleInvoice(
splitSetup = splitZapRequestPair.first,
nostrZapRequest = splitZapRequestPair.second.zapRequestJson,
zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.first.weight, totalWeight),
splitSetup = splitZapRequestPair.inputSetup,
nostrZapRequest = splitZapRequestPair.zapRequestJson,
toUser = splitZapRequestPair.user,
zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.inputSetup.weight, totalWeight),
message = message,
showErrorIfNoLnAddress = showErrorIfNoLnAddress,
forceProxy = forceProxy,
onError = onError,
onProgressStep = { percentStepForThisPayment ->
progressAllPayments += percentStepForThisPayment / invoices.size
progressAllPayments += percentStepForThisPayment / requests.size
onProgress(progressAllPayments)
},
context = context,
@@ -256,30 +258,35 @@ class ZapPaymentHandler(
)
}
class Paid(
payable: Payable,
success: Boolean,
)
suspend fun payViaNWC(
invoices: List<String>,
payables: List<Payable>,
note: Note,
onError: (String, String) -> Unit,
onError: (String, String, User?) -> Unit,
onProgress: (percent: Float) -> Unit,
context: Context,
onAllDone: suspend (MutableMap<String, Boolean>) -> Unit,
onAllDone: suspend (List<Paid>) -> Unit,
) {
var progressAllPayments = 0.00f
collectSuccessfulSigningOperations<String, Boolean>(
operationsInput = invoices,
runRequestFor = { invoice: String, onReady ->
collectSuccessfulOperations<Payable, Paid>(
items = payables,
runRequestFor = { payable: Payable, onReady ->
account.sendZapPaymentRequestFor(
bolt11 = invoice,
bolt11 = payable.invoice,
zappedNote = note,
onSent = {
progressAllPayments += 0.5f / invoices.size
progressAllPayments += 0.5f / payables.size
onProgress(progressAllPayments)
onReady(true)
onReady(Paid(payable, true))
},
onResponse = { response ->
if (response is PayInvoiceErrorResponse) {
progressAllPayments += 0.5f / invoices.size
progressAllPayments += 0.5f / payables.size
onProgress(progressAllPayments)
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
@@ -289,9 +296,10 @@ class ZapPaymentHandler(
response.error?.message
?: response.error?.code?.toString() ?: "Error parsing error message",
),
payable.user,
)
} else {
progressAllPayments += 0.5f / invoices.size
progressAllPayments += 0.5f / payables.size
onProgress(progressAllPayments)
}
},
@@ -301,31 +309,26 @@ class ZapPaymentHandler(
)
}
class AssembleInvoiceReturn(
val zapValue: Long,
val invoice: String,
)
private fun assembleInvoice(
splitSetup: ZapSplitSetup,
nostrZapRequest: String,
nostrZapRequest: String?,
toUser: User?,
zapValue: Long,
message: String,
showErrorIfNoLnAddress: Boolean = true,
onError: (String, String) -> Unit,
forceProxy: (String) -> Boolean,
onError: (String, String, User?) -> Unit,
onProgressStep: (percent: Float) -> Unit,
context: Context,
onReady: (AssembleInvoiceReturn) -> Unit,
onReady: (Payable) -> Unit,
) {
var progressThisPayment = 0.00f
var user: User? = null
val lud16 =
if (splitSetup.isLnAddress) {
splitSetup.lnAddressOrPubKeyHex
} else {
user = LocalCache.getUserIfExists(splitSetup.lnAddressOrPubKeyHex)
user?.info?.lnAddress()
toUser?.info?.lnAddress()
}
if (lud16 != null) {
@@ -335,7 +338,10 @@ class ZapPaymentHandler(
milliSats = zapValue,
message = message,
nostrRequest = nostrZapRequest,
onError = onError,
forceProxy = forceProxy,
onError = { title, msg ->
onError(title, msg, toUser)
},
onProgress = {
val step = it - progressThisPayment
progressThisPayment = it
@@ -344,7 +350,14 @@ class ZapPaymentHandler(
context = context,
onSuccess = {
onProgressStep(1 - progressThisPayment)
onReady(AssembleInvoiceReturn(zapValue, it))
onReady(
Payable(
info = splitSetup,
user = toUser,
amountMilliSats = zapValue,
invoice = it,
),
)
},
)
} else {
@@ -359,6 +372,7 @@ class ZapPaymentHandler(
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
user?.toBestDisplayName() ?: splitSetup.lnAddressOrPubKeyHex,
),
null,
)
}
}
@@ -375,7 +389,6 @@ class ZapPaymentHandler(
) {
if (zapType != LnZapEvent.ZapType.NONZAP) {
account.createZapRequestFor(note, pollOption, message, zapType, overrideUser, additionalRelays) { zapRequest ->
println("Zap Request " + zapRequest.toJson())
onReady(zapRequest.toJson())
}
} else {

View File

@@ -24,20 +24,20 @@ import android.content.Context
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.Lud06
import okhttp3.Request
import okhttp3.Response
import java.math.BigDecimal
import java.math.RoundingMode
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 +54,7 @@ class LightningAddressResolver {
private fun fetchLightningAddressJson(
lnaddress: String,
forceProxy: (url: String) -> Boolean,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
@@ -74,6 +75,8 @@ class LightningAddressResolver {
return
}
val client = HttpClientManager.getHttpClient(forceProxy(url))
try {
val request: Request =
Request
@@ -94,7 +97,7 @@ class LightningAddressResolver {
.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct,
url,
lnaddress,
it.code.toString(),
errorMessage(it, context),
),
)
}
@@ -121,6 +124,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 +141,8 @@ class LightningAddressResolver {
url += "&nostr=$encodedNostrRequest"
}
val client = HttpClientManager.getHttpClient(forceProxy(url))
val request: Request =
Request
.Builder()
@@ -150,17 +156,42 @@ class LightningAddressResolver {
} else {
onError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(context, R.string.could_not_fetch_invoice_from, lnCallback),
stringRes(context, R.string.could_not_fetch_invoice_from_details, lnCallback, errorMessage(it, context)),
)
}
}
}
fun errorMessage(
response: Response,
context: Context,
): String {
val errorMessage =
runCatching {
jacksonObjectMapper().readTree(response.body.string())
}.getOrNull()?.let { tree ->
val status = tree.get("status")?.asText()
val message = tree.get("message")?.asText()
if (status == "error" && message != null) {
message
} else {
tree.get("error")?.get("message")?.asText()
}
}
return errorMessage
?: HttpStatusMessages.resourceIdFor(response.code)?.let { stringRes(context, it) }
?: response.message.ifBlank { null }
?: response.code.toString()
}
fun lnAddressInvoice(
lnaddress: String,
milliSats: Long,
message: String,
nostrRequest: String? = null,
forceProxy: (url: String) -> Boolean,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
@@ -170,6 +201,7 @@ class LightningAddressResolver {
fetchLightningAddressJson(
lnaddress,
forceProxy,
onSuccess = { lnAddressJson ->
onProgress(0.4f)
@@ -189,7 +221,7 @@ class LightningAddressResolver {
null
}
val callback = lnurlp?.get("callback")?.asText()
val callback = lnurlp?.get("callback")?.asText()?.ifBlank { null }
if (callback == null) {
onError(
@@ -210,6 +242,7 @@ class LightningAddressResolver {
milliSats,
message,
if (allowsNostr) nostrRequest else null,
forceProxy,
onSuccess = {
onProgress(0.6f)

View File

@@ -24,47 +24,56 @@ 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.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.LnZapEvent
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 kotlinx.collections.immutable.persistentSetOf
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
// PushNotification Wraps don't include a receiver.
// 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}")
}
}
}
@@ -73,28 +82,84 @@ class EventNotificationConsumer(
private suspend fun consumeIfMatchesAccount(
pushWrappedEvent: GiftWrapEvent,
account: Account,
account: AccountSettings,
) {
pushWrappedEvent.cachedGift(account.signer) { notificationEvent ->
val consumed = LocalCache.hasConsumed(notificationEvent)
val verified = LocalCache.justVerify(notificationEvent)
Log.d("EventNotificationConsumer", "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${account.userProfile().toBestDisplayName()} consumed= $consumed && verified= $verified")
if (!consumed && verified) {
Log.d("EventNotificationConsumer", "New Notification was verified")
unwrapAndConsume(notificationEvent, account) { innerEvent ->
// 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,
)
}
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, account)
} else if (innerEvent is LnZapEvent) {
Log.d("EventNotificationConsumer", "New Zap to Notify")
notify(innerEvent, account)
} else if (innerEvent is ChatMessageEvent) {
Log.d("EventNotificationConsumer", "New ChatMessage to Notify")
notify(innerEvent, account)
pushWrappedEvent.unwrapThrowing(signer) { notificationEvent ->
consumeNotificationEvent(notificationEvent, signer, account)
}
}
fun consumeNotificationEvent(
notificationEvent: Event,
signer: NostrSigner,
account: AccountSettings,
) {
val consumed = LocalCache.hasConsumed(notificationEvent)
val verified = LocalCache.justVerify(notificationEvent)
Log.d(TAG, "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${signer.pubKey} consumed= $consumed && verified= $verified")
if (!consumed && verified) {
Log.d(TAG, "New Notification was verified")
unwrapAndConsume(notificationEvent, signer) { innerEvent ->
if (!notificationManager().areNotificationsEnabled()) return@unwrapAndConsume
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)
} else if (innerEvent is ChatMessageEncryptedFileHeaderEvent) {
Log.d(TAG, "New ChatMessage File to Notify")
notify(innerEvent, signer, account)
}
}
}
}
suspend fun findAccountAndConsume(event: Event) {
Log.d(TAG, "New Notification Arrived")
if (!LocalCache.justVerify(event)) return
val users = event.taggedUsers().map { LocalCache.getOrCreateUser(it) }
val npubs = users.map { it.pubkeyNpub() }.toSet()
// PushNotification Wraps don't include a receiver.
// Test with all logged in accounts
var matchAccount = false
LocalPreferences.allSavedAccounts().forEach {
if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner) && it.npub in npubs) {
LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc ->
Log.d(TAG, "New Notification Testing if for ${it.npub}")
try {
// TODO: Modify the external launcher to launch as different users.
// Right now it only registers if Amber has already approved this signature
val signer = acc.createSigner()
if (signer is NostrSignerExternal) {
signer.launcher.registerLauncher(
launcher = { },
contentResolver = Amethyst.instance::contentResolverFn,
)
}
consumeNotificationEvent(event, signer, acc)
matchAccount = true
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.d(TAG, "Message was not for user ${it.npub}: ${e.message}")
}
}
}
@@ -103,7 +168,7 @@ class EventNotificationConsumer(
private fun unwrapAndConsume(
event: Event,
account: Account,
signer: NostrSigner,
onReady: (Event) -> Unit,
) {
if (!LocalCache.justVerify(event)) return
@@ -111,15 +176,19 @@ class EventNotificationConsumer(
when (event) {
is GiftWrapEvent -> {
event.cachedGift(account.signer) { unwrapAndConsume(it, account, onReady) }
event.unwrap(signer) {
unwrapAndConsume(it, signer, onReady)
LocalCache.justConsume(event, null)
}
}
is SealedGossipEvent -> {
event.cachedGossip(account.signer) {
event.unseal(signer) {
if (!LocalCache.hasConsumed(it)) {
// this is not verifiable
LocalCache.justConsume(it, null)
onReady(it)
}
LocalCache.justConsume(event, null)
}
}
else -> {
@@ -130,32 +199,79 @@ class EventNotificationConsumer(
}
private fun notify(
event: ChatMessageEvent,
acc: Account,
event: ChatMessageEncryptedFileHeaderEvent,
signer: NostrSigner,
acc: AccountSettings,
) {
if (
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
// old event being re-broadcasted
event.pubKey != acc.userProfile().pubkeyHex
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
// don't display if it comes from me.
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(acc.keyPair.pubKey.toHexKey())
val chatRoom = event.chatroomKey(signer.pubKey)
val followingKeySet = acc.followingKeySet()
val followingKeySet = acc.backupContactList?.unverifiedFollowKeySet()?.toSet() ?: return
val isKnownRoom =
(
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(chatRoom)
) &&
!acc.isAllHidden(chatRoom.users)
myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
myUser.hasSentMessagesTo(chatRoom)
)
if (isKnownRoom) {
val content = chatNote.event?.content() ?: ""
val user = chatNote.author?.toBestDisplayName() ?: ""
val userPicture = chatNote.author?.profilePicture()
val noteUri = chatNote.toNEvent()
val noteUri = chatNote.toNEvent() + "?account=" + acc.keyPair.pubKey.toNpub()
// TODO: Show Image on notification
notificationManager()
.sendDMNotification(
event.id,
content,
user,
event.createdAt,
userPicture,
noteUri,
applicationContext,
)
}
}
}
private fun notify(
event: ChatMessageEvent,
signer: NostrSigner,
acc: AccountSettings,
) {
if (
// old event being re-broadcasted
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
// don't display if it comes from me.
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)
val followingKeySet = acc.backupContactList?.unverifiedFollowKeySet()?.toSet() ?: return
val isKnownRoom =
(
myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
myUser.hasSentMessagesTo(chatRoom)
)
if (isKnownRoom) {
val content = chatNote.event?.content() ?: ""
val user = chatNote.author?.toBestDisplayName() ?: ""
val userPicture = chatNote.author?.profilePicture()
val noteUri = chatNote.toNEvent() + "?account=" + acc.keyPair.pubKey.toNpub()
notificationManager()
.sendDMNotification(
event.id,
@@ -172,35 +288,30 @@ class EventNotificationConsumer(
private fun notify(
event: PrivateDmEvent,
acc: Account,
signer: NostrSigner,
acc: AccountSettings,
) {
val note = LocalCache.getNoteIfExists(event.id) ?: return
val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return
// old event being re-broadcast
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
if (acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
val followingKeySet = acc.followingKeySet()
if (signer.pubKey == event.verifiedRecipientPubKey()) {
val followingKeySet = acc.backupContactList?.unverifiedFollowKeySet()?.toSet() ?: return
val knownChatrooms =
acc
.userProfile()
.privateChatrooms
.keys
.filter {
(
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(it)
) &&
!acc.isAllHidden(it.users)
}.toSet()
val chatRoom = event.chatroomKey(signer.pubKey)
note.author?.let {
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
acc.decryptContent(note) { content ->
val isKnownRoom =
myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
myUser.hasSentMessagesTo(chatRoom)
if (isKnownRoom) {
note.author?.let {
decryptContent(note, signer) { content ->
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
val noteUri = note.toNEvent() + "?account=" + acc.keyPair.pubKey.toNpub()
notificationManager()
.sendDMNotification(event.id, content, user, event.createdAt, userPicture, noteUri, applicationContext)
}
@@ -209,45 +320,80 @@ class EventNotificationConsumer(
}
}
fun decryptZapContentAuthor(
note: Note,
signer: NostrSigner,
onReady: (Event) -> Unit,
) {
val event = note.event
if (event is LnZapRequestEvent) {
if (event.isPrivateZap()) {
event.decryptPrivateZap(signer) { onReady(it) }
} else {
onReady(event)
}
}
}
fun decryptContent(
note: Note,
signer: NostrSigner,
onReady: (String) -> Unit,
) {
val event = note.event
if (event is PrivateDmEvent) {
event.plainContent(signer, onReady)
} else if (event is LnZapRequestEvent) {
decryptZapContentAuthor(note, signer) { onReady(it.content) }
} else if (event is DraftEvent) {
event.cachedDraft(signer) {
onReady(it.content)
}
} else {
event?.content()?.let { onReady(it) }
}
}
private fun notify(
event: LnZapEvent,
acc: Account,
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(acc.userProfile().pubkeyHex)) {
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 ->
acc.decryptZapContentAuthor(noteZapRequest) {
Log.d("EventNotificationConsumer", "Notify Decrypted if Private Zap ${event.id}")
decryptZapContentAuthor(noteZapRequest, signer) {
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) {
acc.decryptContent(noteZapped) {
Log.d("EventNotificationConsumer", "Notify Decrypted if Private Note")
decryptContent(noteZapped, signer) {
Log.d(TAG, "Notify Decrypted if Private Note")
val zappedContent = it.split("\n").get(0)
@@ -270,10 +416,10 @@ class EventNotificationConsumer(
zappedContent,
)
}
val userPicture = senderInfo?.first?.profilePicture()
val noteUri = "nostr:Notifications"
val userPicture = senderInfo.first.profilePicture()
val noteUri = "notifications?account=" + acc.keyPair.pubKey.toNpub()
Log.d("EventNotificationConsumer", "Notify ${event.id} $content $title $noteUri")
Log.d(TAG, "Notify ${event.id} $content $title $noteUri")
notificationManager()
.sendZapNotification(
@@ -288,7 +434,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)
@@ -302,9 +448,9 @@ class EventNotificationConsumer(
)
val userPicture = senderInfo.first.profilePicture()
val noteUri = "nostr:Notifications"
val noteUri = "notifications?account=" + acc.keyPair.pubKey.toNpub()
Log.d("EventNotificationConsumer", "Notify ${event.id} $content $title $noteUri")
Log.d(TAG, "Notify ${event.id} $title $noteUri")
notificationManager()
.sendZapNotification(

View File

@@ -29,9 +29,10 @@ import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat
import coil.ImageLoader
import coil.executeBlocking
import coil.request.ImageRequest
import coil3.ImageLoader
import coil3.asDrawable
import coil3.executeBlocking
import coil3.request.ImageRequest
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
@@ -157,7 +158,7 @@ object NotificationUtils {
messageBody = messageBody,
messageTitle = messageTitle,
time = time,
picture = imageResult.drawable as? BitmapDrawable,
picture = imageResult.image?.asDrawable(applicationContext.resources) as? BitmapDrawable,
uri = uri,
channelId,
notificationGroupKey,
@@ -220,9 +221,6 @@ object NotificationUtils {
.setContentTitle(messageTitle)
.setContentText(stringRes(applicationContext, R.string.app_notification_private_message))
.setLargeIcon(picture?.bitmap)
// .setGroup(messageTitle)
// .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we
// activate this
.setContentIntent(contentPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
@@ -238,9 +236,6 @@ object NotificationUtils {
.setContentTitle(messageTitle)
.setContentText(messageBody)
.setLargeIcon(picture?.bitmap)
// .setGroup(messageTitle)
// .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we
// activate this
.setContentIntent(contentPendingIntent)
.setPublicVersion(builderPublic.build())
.setPriority(NotificationCompat.PRIORITY_HIGH)

View File

@@ -0,0 +1,60 @@
/**
* 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.notifications
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import com.vitorpamplona.quartz.events.Event
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class PokeyReceiver : BroadcastReceiver() {
companion object {
val POKEY_ACTION = "com.shared.NOSTR"
val TAG = "PokeyReceiver"
}
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun onReceive(
context: Context,
intent: Intent,
) {
if (intent.action == POKEY_ACTION) { // it's best practice to verify intent action before performing any operation
val eventStr = intent.getStringExtra("EVENT")
Log.d(TAG, "New Pokey Notification Arrived $eventStr")
if (eventStr == null) return
scope.launch(Dispatchers.IO) {
try {
EventNotificationConsumer(context.applicationContext).findAccountAndConsume(Event.fromJson(eventStr))
} catch (e: Exception) {
Log.e(TAG, "Failed to parse Pokey Event", e)
}
}
}
}
}

View File

@@ -22,22 +22,18 @@ 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.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.amethyst.launchAndWaitAll
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.quartz.events.RelayAuthEvent
import com.vitorpamplona.quartz.signers.NostrSignerExternal
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@@ -55,7 +51,7 @@ class RegisterAccounts(
private suspend fun signAllAuths(
notificationToken: String,
remainingTos: List<Pair<Account, String>>,
remainingTos: List<Pair<AccountSettings, List<String>>>,
output: MutableList<RelayAuthEvent>,
onReady: (List<RelayAuthEvent>) -> Unit,
) {
@@ -64,28 +60,26 @@ class RegisterAccounts(
return
}
coroutineScope {
val jobs =
remainingTos.map { accountRelayPair ->
async {
val result =
withTimeoutOrNull(10000) {
suspendCancellableCoroutine { continuation ->
accountRelayPair.first.createAuthEvent(accountRelayPair.second, notificationToken) { result ->
continuation.resume(result)
}
}
}
launchAndWaitAll(remainingTos) { accountRelayPair ->
val result =
tryAndWait { continuation ->
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,
)
}
if (result != null) {
output.add(result)
}
RelayAuthEvent.create(accountRelayPair.second, notificationToken, signer) { result ->
continuation.resume(result)
}
}
// runs in parallel to avoid overcrowding Amber.
withTimeoutOrNull(15000) {
jobs.joinAll()
if (result != null) {
output.add(result)
}
}
@@ -100,44 +94,34 @@ class RegisterAccounts(
) {
val readyToSend =
accounts
.map {
.mapNotNull {
Log.d(tag, "Register Account ${it.npub}")
val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)
if (acc != null && acc.isWriteable()) {
val nip65Read =
(
LocalCache
.getAddressableNoteIfExists(
AdvertisedRelayListEvent.createAddressTag(acc.userProfile().pubkeyHex),
)?.event as? AdvertisedRelayListEvent
)?.readRelays() ?: acc.backupNIP65RelayList?.readRelays() ?: emptyList<String>()
val nip65Read = acc.backupNIP65RelayList?.readRelays() ?: emptyList()
Log.d(tag, "Register Account ${acc.userProfile().toBestDisplayName()} NIP65 Reads ${nip65Read.joinToString(", ")}")
Log.d(tag, "Register Account ${it.npub} NIP65 Reads ${nip65Read.joinToString(", ")}")
val nip17Read =
(
LocalCache
.getAddressableNoteIfExists(
ChatMessageRelayListEvent.createAddressTag(acc.userProfile().pubkeyHex),
)?.event as? ChatMessageRelayListEvent
)?.relays() ?: acc.backupDMRelayList?.relays() ?: emptyList<String>()
val nip17Read = acc.backupDMRelayList?.relays() ?: emptyList<String>()
Log.d(tag, "Register Account ${acc.userProfile().toBestDisplayName()} NIP17 Reads ${nip17Read.joinToString(", ")}")
Log.d(tag, "Register Account ${it.npub} NIP17 Reads ${nip17Read.joinToString(", ")}")
val kind3Relays = (acc.userProfile().latestContactList?.relays() ?: acc.backupContactList?.relays())
val readKind3Relays = kind3Relays?.mapNotNull { if (it.value.read) it.key else null } ?: emptyList<String>()
val readKind3Relays = acc.backupContactList?.relays()?.mapNotNull { if (it.value.read) it.key else null } ?: emptyList<String>()
Log.d(tag, "Register Account ${acc.userProfile().toBestDisplayName()} Kind3 Reads ${readKind3Relays.joinToString(", ")}")
Log.d(tag, "Register Account ${it.npub} Kind3 Reads ${readKind3Relays.joinToString(", ")}")
(nip65Read + nip17Read + readKind3Relays).map {
Pair(acc, it)
val relays = (nip65Read + nip17Read + readKind3Relays)
if (relays.isNotEmpty()) {
Pair(acc, relays)
} else {
null
}
} else {
emptyList<Pair<Account, String>>()
null
}
}.flatten()
}
val listOfAuthEvents = mutableListOf<RelayAuthEvent>()
signAllAuths(
@@ -167,19 +151,22 @@ 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) }
PushNotificationUtils.hasInit = true
suspend fun go(notificationToken: String) {
if (notificationToken.isNotEmpty()) {
withContext(Dispatchers.IO) {
signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) }
}
}
}
}

View File

@@ -18,27 +18,22 @@
* 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
package com.vitorpamplona.amethyst.service.okhttp
import kotlinx.coroutines.CancellationException
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
class BahaUrlPreview(
val url: String,
var callback: IUrlPreviewCallback?,
) {
suspend fun fetchUrlPreview(timeOut: Int = 30000) =
try {
fetch(timeOut)
} catch (t: Throwable) {
if (t is CancellationException) throw t
callback?.onFailed(t)
}
private suspend fun fetch(timeOut: Int = 30000) {
callback?.onComplete(getDocument(url, timeOut))
}
fun cleanUp() {
callback = null
class DefaultContentTypeInterceptor(
private val userAgentHeader: String,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest: Request = chain.request()
val requestWithUserAgent: Request =
originalRequest
.newBuilder()
.header("User-Agent", userAgentHeader)
.build()
return chain.proceed(requestWithUserAgent)
}
}

View File

@@ -0,0 +1,72 @@
/**
* 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.okhttp
import android.util.Log
import com.vitorpamplona.quartz.crypto.nip17.AESGCM
import com.vitorpamplona.quartz.crypto.nip17.NostrCipher
import okhttp3.Interceptor
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
class EncryptedBlobInterceptor(
val cache: EncryptionKeyCache,
) : Interceptor {
fun Response.decrypt(cipher: NostrCipher): Response {
val body = peekBody(Long.MAX_VALUE)
val decryptedBytes = cipher.decrypt(body.bytes())
val newBody = decryptedBytes.toResponseBody(body.contentType())
return newBuilder().body(newBody).build()
}
fun Response.decryptOrNull(cipher: NostrCipher): Response? =
try {
decrypt(cipher)
} catch (e: Exception) {
Log.w("EncryptedBlobInterceptor", "Failed to decrypt", e)
null
}
private fun Response.decryptOrNullWithErrorCorrection(cipher: NostrCipher): Response? {
return decryptOrNull(cipher) ?: return if (cipher is AESGCM) {
decryptOrNull(cipher.copyUsingUTF8Nonce())
} else {
null
}
}
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
val cipher = cache.get(request.url.toString()) ?: return response
if (response.isSuccessful) {
return response.decryptOrNullWithErrorCorrection(cipher) ?: response
} else {
// Log redirections to be able to use the cipher.
response.header("Location")?.let {
cache.add(it, cipher)
}
}
return response
}
}

View File

@@ -0,0 +1,45 @@
/**
* 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.okhttp
import android.util.LruCache
import com.vitorpamplona.quartz.crypto.nip17.NostrCipher
/**
* Neigther ExoPlayer, nor Coil support passing key and nonce to the Interceptor via
* Request.tag, which would be the right way to do this.
*
* This class serves as a key cache to decrypt the body of HTTP calls that need it.
*/
class EncryptionKeyCache {
val cache = LruCache<String, NostrCipher>(100)
fun add(
url: String?,
cipher: NostrCipher,
) {
if (cache.get(url) == null) {
cache.put(url, cipher)
}
}
fun get(url: String): NostrCipher? = cache.get(url)
}

View File

@@ -18,126 +18,111 @@
* 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.ammolite.service
package com.vitorpamplona.amethyst.service.okhttp
import android.util.Log
import okhttp3.Interceptor
import com.vitorpamplona.quartz.crypto.nip17.NostrCipher
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Proxy
import java.time.Duration
import kotlin.properties.Delegates
object HttpClientManager {
private val rootClient =
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
val DEFAULT_TIMEOUT_ON_WIFI: Duration = Duration.ofSeconds(10L)
val DEFAULT_TIMEOUT_ON_MOBILE: Duration = Duration.ofSeconds(30L)
var proxyChangeListeners = ArrayList<() -> Unit>()
private var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI
private var defaultHttpClient: OkHttpClient? = null
private var defaultHttpClientWithoutProxy: OkHttpClient? = null
private var userAgent: String = "Amethyst"
// fires off every time value of the property changes
private var internalProxy: Proxy? by
Delegates.observable(null) { _, oldValue, newValue ->
if (oldValue != newValue) {
proxyChangeListeners.forEach { it() }
}
}
private var currentProxy: Proxy? = null
private val cache = EncryptionKeyCache()
fun setDefaultProxy(proxy: Proxy?) {
if (internalProxy != proxy) {
if (currentProxy != proxy) {
Log.d("HttpClient", "Changing proxy to: ${proxy != null}")
this.internalProxy = proxy
currentProxy = proxy
// recreates singleton
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
defaultHttpClient = buildHttpClient(currentProxy, defaultTimeout)
}
}
fun getDefaultProxy(): Proxy? = this.internalProxy
fun getCurrentProxy(): Proxy? = currentProxy
fun setDefaultTimeout(timeout: Duration) {
Log.d("HttpClient", "Changing timeout to: $timeout")
if (this.defaultTimeout.seconds != timeout.seconds) {
this.defaultTimeout = timeout
if (defaultTimeout.seconds != timeout.seconds) {
defaultTimeout = timeout
// recreates singleton
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
defaultHttpClient = buildHttpClient(currentProxy, defaultTimeout)
defaultHttpClientWithoutProxy = buildHttpClient(null, defaultTimeout)
}
}
fun setDefaultUserAgent(userAgentHeader: String) {
Log.d("HttpClient", "Changing userAgent")
this.userAgent = userAgentHeader
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
if (userAgent != userAgentHeader) {
userAgent = userAgentHeader
defaultHttpClient = buildHttpClient(currentProxy, defaultTimeout)
defaultHttpClientWithoutProxy = buildHttpClient(null, defaultTimeout)
}
}
fun getDefaultUserAgentHeader() = this.userAgent
private fun buildHttpClient(
proxy: Proxy?,
timeout: Duration,
): OkHttpClient {
val seconds = if (proxy != null) timeout.seconds * 3 else timeout.seconds
val duration = Duration.ofSeconds(seconds)
return OkHttpClient
.Builder()
return rootClient
.newBuilder()
.proxy(proxy)
.readTimeout(duration)
.connectTimeout(duration)
.writeTimeout(duration)
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
.followRedirects(true)
.followSslRedirects(true)
.addNetworkInterceptor(LoggingInterceptor())
.addNetworkInterceptor(EncryptedBlobInterceptor(cache))
.build()
}
class DefaultContentTypeInterceptor(
private val userAgentHeader: String,
) : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest: Request = chain.request()
val requestWithUserAgent: Request =
originalRequest
.newBuilder()
.header("User-Agent", userAgentHeader)
.build()
return chain.proceed(requestWithUserAgent)
}
}
fun getHttpClientForUrl(url: String): OkHttpClient {
// TODO: How to identify relays on the local network?
val isLocalHost = url.startsWith("ws://127.0.0.1") || url.startsWith("ws://localhost")
return if (isLocalHost) {
getHttpClient(false)
} else {
getHttpClient()
}
}
fun getHttpClient(useProxy: Boolean = true): OkHttpClient =
fun getCurrentProxyPort(useProxy: Boolean): Int? =
if (useProxy) {
if (this.defaultHttpClient == null) {
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
(currentProxy?.address() as? InetSocketAddress)?.port
} else {
null
}
fun getHttpClient(useProxy: Boolean): OkHttpClient =
if (useProxy) {
if (defaultHttpClient == null) {
defaultHttpClient = buildHttpClient(currentProxy, defaultTimeout)
}
defaultHttpClient!!
} else {
if (this.defaultHttpClientWithoutProxy == null) {
this.defaultHttpClientWithoutProxy = buildHttpClient(null, defaultTimeout)
if (defaultHttpClientWithoutProxy == null) {
defaultHttpClientWithoutProxy = buildHttpClient(null, defaultTimeout)
}
defaultHttpClientWithoutProxy!!
}
fun initProxy(
useProxy: Boolean,
hostname: String,
port: Int,
): Proxy? = if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) else null
fun setDefaultProxyOnPort(port: Int) {
setDefaultProxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port)))
}
fun addCipherToCache(
url: String,
cipher: NostrCipher,
) = cache.add(url, cipher)
}

View File

@@ -0,0 +1,48 @@
/**
* 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.okhttp
import android.util.Log
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import java.net.InetSocketAddress
class LoggingInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request: Request = chain.request()
val t1 = System.nanoTime()
val port =
(
chain
.connection()
?.route()
?.proxy
?.address() as? InetSocketAddress
)?.port
val response: Response = chain.proceed(request)
val t2 = System.nanoTime()
Log.d("OkHttpLog", "Req $port ${request.url} in ${(t2 - t1) / 1e6}ms")
return response
}
}

View File

@@ -0,0 +1,89 @@
/**
* 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.okhttp
import com.vitorpamplona.ammolite.sockets.WebSocket
import com.vitorpamplona.ammolite.sockets.WebSocketListener
import com.vitorpamplona.ammolite.sockets.WebsocketBuilder
import okhttp3.Request
import okhttp3.Response
class OkHttpWebSocket(
val url: String,
val forceProxy: Boolean,
val out: WebSocketListener,
) : WebSocket {
private val listener = OkHttpWebsocketListener()
private var socket: okhttp3.WebSocket? = null
fun buildRequest() = Request.Builder().url(url.trim()).build()
override fun connect() {
socket = HttpClientManager.getHttpClient(forceProxy).newWebSocket(buildRequest(), listener)
}
inner class OkHttpWebsocketListener : okhttp3.WebSocketListener() {
override fun onOpen(
webSocket: okhttp3.WebSocket,
response: Response,
) = out.onOpen(
response.receivedResponseAtMillis - response.sentRequestAtMillis,
response.headers.get("Sec-WebSocket-Extensions")?.contains("permessage-deflate") ?: false,
)
override fun onMessage(
webSocket: okhttp3.WebSocket,
text: String,
) = out.onMessage(text)
override fun onClosing(
webSocket: okhttp3.WebSocket,
code: Int,
reason: String,
) = out.onClosing(code, reason)
override fun onClosed(
webSocket: okhttp3.WebSocket,
code: Int,
reason: String,
) = out.onClosed(code, reason)
override fun onFailure(
webSocket: okhttp3.WebSocket,
t: Throwable,
response: Response?,
) = out.onFailure(t, response?.message)
}
class Builder : WebsocketBuilder {
override fun build(
url: String,
forceProxy: Boolean,
out: WebSocketListener,
) = OkHttpWebSocket(url, forceProxy, out)
}
override fun cancel() {
socket?.cancel()
}
override fun send(msg: String): Boolean = socket?.send(msg) ?: false
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ import androidx.media3.common.Player
import androidx.media3.common.Player.PositionInfo
import androidx.media3.common.Player.STATE_IDLE
import androidx.media3.common.Player.STATE_READY
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import com.vitorpamplona.amethyst.ui.MainActivity
@@ -40,14 +41,15 @@ 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
private val playingMap = mutableMapOf<String, MediaSession>()
private val cache =
object : LruCache<String, MediaSession>(10) { // up to 10 videos in the screen at the same time
object : LruCache<String, MediaSession>(20) { // up to 10 videos in the screen at the same time
override fun entryRemoved(
evicted: Boolean,
key: String?,
@@ -89,7 +91,7 @@ class MultiPlayerPlaybackManager(
val player =
ExoPlayer.Builder(context).run {
dataSourceFactory?.let { setMediaSourceFactory(it) }
setMediaSourceFactory(dataSourceFactory)
build()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.filters.EOSETime
class EOSERelayList(
var relayList: Map<String, EOSETime> = emptyMap(),

View File

@@ -0,0 +1,68 @@
/**
* 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.uploads
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.crypto.nip17.NostrCipher
import com.vitorpamplona.quartz.encoders.toHexKey
import java.io.File
class EncryptFilesResult(
val uri: Uri,
val contentType: String?,
val originalHash: String,
val encryptedHash: String,
val size: Long?,
)
class EncryptFiles {
fun encryptFile(
context: Context,
inputFile: Uri,
cipher: NostrCipher,
): EncryptFilesResult {
val resolver = context.contentResolver
val encryptedFile = File.createTempFile("EncryptFiles", ".encrypted", context.getCacheDir())
resolver.openInputStream(inputFile)!!.use { inputStream ->
val bytes = inputStream.readBytes()
val originalHash = CryptoUtils.sha256(bytes).toHexKey()
val encrypted = cipher.encrypt(bytes)
val encryptedHash = CryptoUtils.sha256(encrypted).toHexKey()
encryptedFile.outputStream().use { outputStream ->
outputStream.write(encrypted)
}
return EncryptFilesResult(
encryptedFile.toUri(),
"application/octet-stream",
originalHash,
encryptedHash,
encrypted.size.toLong(),
)
}
}
}

View File

@@ -18,7 +18,7 @@
* 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
package com.vitorpamplona.amethyst.service.uploads
import android.graphics.Bitmap
import android.graphics.BitmapFactory
@@ -28,51 +28,51 @@ import android.media.MediaMetadataRetriever
import android.media.MediaMetadataRetriever.BitmapParams
import android.os.Build
import android.util.Log
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash
import com.vitorpamplona.amethyst.service.Blurhash
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.Dimension
import com.vitorpamplona.quartz.encoders.toHexKey
import io.trbl.blurhash.BlurHash
import kotlinx.coroutines.CancellationException
import java.io.IOException
import kotlin.math.roundToInt
class FileHeader(
val mimeType: String?,
val hash: String,
val size: Int,
val dim: String?,
val blurHash: String?,
val dim: Dimension?,
val blurHash: Blurhash?,
) {
class UnableToDownload(
val fileUrl: String,
) : Exception()
companion object {
suspend fun prepare(
fileUrl: String,
mimeType: String?,
dimPrecomputed: String?,
onReady: (FileHeader) -> Unit,
onError: (String?) -> Unit,
) {
dimPrecomputed: Dimension?,
forceProxy: Boolean,
): Result<FileHeader> =
try {
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl, forceProxy)
if (imageData != null) {
prepare(imageData, mimeType, dimPrecomputed, onReady, onError)
prepare(imageData, mimeType, dimPrecomputed)
} else {
onError(null)
Result.failure(UnableToDownload(fileUrl))
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
onError(e.message)
Result.failure(e)
}
}
fun prepare(
data: ByteArray,
mimeType: String?,
dimPrecomputed: String?,
onReady: (FileHeader) -> Unit,
onError: (String?) -> Unit,
) {
dimPrecomputed: Dimension?,
): Result<FileHeader> =
try {
val hash = CryptoUtils.sha256(data).toHexKey()
val size = data.size
@@ -82,90 +82,15 @@ class FileHeader(
val opt = BitmapFactory.Options()
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
val intArray = IntArray(mBitmap.width * mBitmap.height)
mBitmap.getPixels(
intArray,
0,
mBitmap.width,
0,
0,
mBitmap.width,
mBitmap.height,
)
val dim = "${mBitmap.width}x${mBitmap.height}"
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
if (aspectRatio > 1) {
Pair(
BlurHash.encode(
intArray,
mBitmap.width,
mBitmap.height,
9,
(9 * (1 / aspectRatio)).roundToInt(),
),
dim,
)
} else if (aspectRatio < 1) {
Pair(
BlurHash.encode(
intArray,
mBitmap.width,
mBitmap.height,
(9 * aspectRatio).roundToInt(),
9,
),
dim,
)
} else {
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
}
Pair(Blurhash(mBitmap.toBlurhash()), Dimension(mBitmap.width, mBitmap.height))
} else if (mimeType?.startsWith("video/") == true) {
val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data))
val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed
val blurhash = mediaMetadataRetriever.getThumbnail()?.toBlurhash()?.let { Blurhash(it) }
val blurhash =
mediaMetadataRetriever.getThumbnail()?.let { thumbnail ->
val aspectRatio = (thumbnail.width).toFloat() / (thumbnail.height).toFloat()
val intArray = IntArray(thumbnail.width * thumbnail.height)
thumbnail.getPixels(
intArray,
0,
thumbnail.width,
0,
0,
thumbnail.width,
thumbnail.height,
)
if (aspectRatio > 1) {
BlurHash.encode(
intArray,
thumbnail.width,
thumbnail.height,
9,
(9 * (1 / aspectRatio)).roundToInt(),
)
} else if (aspectRatio < 1) {
BlurHash.encode(
intArray,
thumbnail.width,
thumbnail.height,
(9 * aspectRatio).roundToInt(),
9,
)
} else {
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 4, 4)
}
}
if (newDim != "0x0") {
if (newDim?.hasSize() == true) {
Pair(blurhash, newDim)
} else {
Pair(blurhash, null)
@@ -174,13 +99,12 @@ class FileHeader(
Pair(null, null)
}
onReady(FileHeader(mimeType, hash, size, dim, blurHash))
Result.success(FileHeader(mimeType, hash, size, dim, blurHash))
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError(e.message)
Result.failure(e)
}
}
}
}
@@ -211,11 +135,15 @@ fun MediaMetadataRetriever.getThumbnail(): Bitmap? {
}
}
fun MediaMetadataRetriever.prepareDimFromVideo(): String? {
fun MediaMetadataRetriever.prepareDimFromVideo(): Dimension? {
val width = prepareVideoWidth() ?: return null
val height = prepareVideoHeight() ?: return null
return "${width}x$height"
return if (width > 0 && height > 0) {
Dimension(width, height)
} else {
null
}
}
fun MediaMetadataRetriever.prepareVideoWidth(): Int? {

View File

@@ -0,0 +1,96 @@
/**
* 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.uploads
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
class ImageDownloader {
suspend fun waitAndGetImage(
imageUrl: String,
forceProxy: Boolean,
): ByteArray? =
withContext(Dispatchers.IO) {
var imageData: ByteArray? = null
var tentatives = 0
// Servers are usually not ready.. so tries to download it for 15 times/seconds.
while (imageData == null && tentatives < 15) {
imageData =
try {
tryGetTheImage(imageUrl, forceProxy)
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
if (imageData == null) {
tentatives++
delay(1000)
}
}
return@withContext imageData
}
private suspend fun tryGetTheImage(
imageUrl: String,
forceProxy: Boolean,
): ByteArray? =
withContext(Dispatchers.IO) {
// TODO: Migrate to OkHttp
HttpURLConnection.setFollowRedirects(true)
var url = URL(imageUrl)
var huc =
if (forceProxy) {
url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection
} else {
url.openConnection() as HttpURLConnection
}
huc.instanceFollowRedirects = true
var responseCode = huc.responseCode
if (responseCode in 300..400) {
val newUrl: String = huc.getHeaderField("Location")
// open the new connnection again
url = URL(newUrl)
huc =
if (forceProxy) {
url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection
} else {
url.openConnection() as HttpURLConnection
}
responseCode = huc.responseCode
}
return@withContext if (responseCode in 200..300) {
huc.inputStream.use { it.readBytes() }
} else {
null
}
}
}

View File

@@ -0,0 +1,221 @@
/**
* 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.uploads
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.util.Log
import androidx.core.net.toUri
import com.abedelazizshe.lightcompressorlibrary.CompressionListener
import com.abedelazizshe.lightcompressorlibrary.VideoCompressor
import com.abedelazizshe.lightcompressorlibrary.VideoQuality
import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration
import com.abedelazizshe.lightcompressorlibrary.config.Configuration
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 kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import java.io.File
import java.util.UUID
import kotlin.coroutines.resume
class MediaCompressorResult(
val uri: Uri,
val contentType: String?,
val size: Long?,
)
class MediaCompressor {
// ALL ERRORS ARE IGNORED. The original file is returned.
suspend fun compress(
uri: Uri,
contentType: String?,
mediaQuality: CompressorQuality,
applicationContext: Context,
): MediaCompressorResult {
// Skip compression if user selected uncompressed
if (mediaQuality == CompressorQuality.UNCOMPRESSED) {
Log.d("MediaCompressor", "UNCOMPRESSED quality selected, skipping compression.")
return MediaCompressorResult(uri, contentType, null)
}
checkNotInMainThread()
// branch into compression based on content type
return when {
contentType?.startsWith("video", ignoreCase = true) == true -> compressVideo(uri, contentType, applicationContext, mediaQuality)
contentType?.startsWith("image", ignoreCase = true) == true &&
!contentType.contains("gif") &&
!contentType.contains("svg") ->
compressImage(uri, contentType, applicationContext, mediaQuality)
else -> MediaCompressorResult(uri, contentType, null)
}
}
private suspend fun compressVideo(
uri: Uri,
contentType: String?,
applicationContext: Context,
mediaQuality: CompressorQuality,
): MediaCompressorResult {
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
}
Log.d("MediaCompressor", "Using video compression $mediaQuality")
val result =
withTimeoutOrNull(30000) {
suspendCancellableCoroutine { continuation ->
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]")
continuation.resume(MediaCompressorResult(Uri.fromFile(File(path)), contentType, size))
} else {
Log.d("MediaCompressor", "Video compression successful, but returned null path")
continuation.resume(null)
}
}
override fun onFailure(
index: Int,
failureMessage: String,
) {
Log.d("MediaCompressor", "Video compression failed: $failureMessage")
// keeps going with original video
continuation.resume(null)
}
override fun onCancelled(index: Int) {
continuation.resume(null)
}
},
)
}
}
return result ?: MediaCompressorResult(uri, contentType, null)
}
private suspend fun compressImage(
uri: Uri,
contentType: String?,
context: Context,
mediaQuality: CompressorQuality,
): MediaCompressorResult {
val imageQuality =
when (mediaQuality) {
CompressorQuality.VERY_LOW -> 40
CompressorQuality.LOW -> 50
CompressorQuality.MEDIUM -> 60
CompressorQuality.HIGH -> 80
CompressorQuality.VERY_HIGH -> 90
else -> 60
}
return 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()}]")
MediaCompressorResult(compressedImageFile.toUri(), contentType, compressedImageFile.length())
} catch (e: Exception) {
Log.d("MediaCompressor", "Image compression failed: ${e.message}")
if (e is CancellationException) throw e
e.printStackTrace()
MediaCompressorResult(uri, contentType, null)
}
}
companion object {
fun intToCompressorQuality(mediaQualityFloat: Int): CompressorQuality =
when (mediaQualityFloat) {
0 -> CompressorQuality.LOW
1 -> CompressorQuality.MEDIUM
2 -> CompressorQuality.HIGH
3 -> CompressorQuality.UNCOMPRESSED
else -> CompressorQuality.MEDIUM
}
fun compressorQualityToInt(compressorQuality: CompressorQuality): Int =
when (compressorQuality) {
CompressorQuality.LOW -> 0
CompressorQuality.MEDIUM -> 1
CompressorQuality.HIGH -> 2
CompressorQuality.UNCOMPRESSED -> 3
else -> 1
}
}
}
enum class CompressorQuality {
VERY_LOW,
LOW,
MEDIUM,
HIGH,
VERY_HIGH,
UNCOMPRESSED,
}

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