Compare commits

...

472 Commits

Author SHA1 Message Date
Vitor Pamplona
60e87d943a v0.83.13 2024-02-12 16:09:03 -05:00
Vitor Pamplona
1deed59d5b Fixes: https://github.com/vitorpamplona/amethyst/issues/740
Displays error messages when the URI sent to Amethyst is invalid
2024-02-12 16:03:37 -05:00
Vitor Pamplona
bbc4ec2625 Fixes thread rendering when "mention" events are added with mentioning the event. 2024-02-12 15:10:48 -05:00
Vitor Pamplona
5109b65e37 Fixes crash when MuteList has e tags that are not valid hexes 2024-02-12 14:21:32 -05:00
Vitor Pamplona
c0a9ce8e21 Small refactoring to focus the encrypted storage procedures to the application context. 2024-02-12 11:52:18 -05:00
Vitor Pamplona
bdff8c6c0d Moves language translation cleanup to the IO thread since it clears disk files as well 2024-02-12 11:15:59 -05:00
Vitor Pamplona
a3ac8a445d Merge pull request #768 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-10 10:22:09 -05:00
Crowdin Bot
84d4d68116 New Crowdin translations by GitHub Action 2024-02-10 15:00:43 +00:00
Vitor Pamplona
27248bff17 Invert the order of the hidden users in the screen. 2024-02-10 09:59:01 -05:00
Vitor Pamplona
dc9a9f288b Merge pull request #767 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-10 08:40:44 -05:00
Crowdin Bot
88662a4e55 New Crowdin translations by GitHub Action 2024-02-10 13:27:57 +00:00
Vitor Pamplona
9d269f7cba Merge pull request #766 from davotoula/update_translations
Updated translations. DE, SV, PT
2024-02-10 08:26:53 -05:00
David Kaspar
04fe439e0b Updated translations. DE, SV, PT 2024-02-10 09:45:17 +00:00
Vitor Pamplona
72503b9061 Adds community and public chat list events from nip51 2024-02-09 15:54:56 -05:00
Vitor Pamplona
f7ab963db9 Small fixes in the use of modifiers 2024-02-09 14:37:49 -05:00
Vitor Pamplona
f97128e11c Fixing accessibility issues raised on https://github.com/vitorpamplona/amethyst/issues/765 2024-02-08 17:16:54 -05:00
Vitor Pamplona
e2d4107297 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-02-08 14:54:04 -05:00
Vitor Pamplona
18709a1b22 Disables the notification dot in the discovery tab 2024-02-08 14:53:53 -05:00
Vitor Pamplona
ddfe2abb42 Fixes send to Top in marketplaces. 2024-02-08 14:53:35 -05:00
Vitor Pamplona
0cf82f7edd Merge pull request #764 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-07 16:37:18 -05:00
Crowdin Bot
d1c40efd90 New Crowdin translations by GitHub Action 2024-02-07 21:16:44 +00:00
Vitor Pamplona
68e90ce3ed Unwrapps the reply message if the GiftWrap was tagged and not the inner message id. 2024-02-07 16:14:48 -05:00
Vitor Pamplona
95e19cf681 v0.83.12 2024-02-05 20:29:43 -05:00
Vitor Pamplona
3298fd4493 BugFix for short lists 2024-02-05 20:26:37 -05:00
Vitor Pamplona
248bdffd50 v0.83.11 2024-02-05 19:49:27 -05:00
Vitor Pamplona
cce7a20918 Speeds up parsing of the event content. 2024-02-05 19:47:33 -05:00
Vitor Pamplona
845c99b798 Faster Expandable Text calculator. 2024-02-05 19:37:21 -05:00
Vitor Pamplona
b13b832696 Starting a new module for app benchmarks 2024-02-05 18:18:33 -05:00
Vitor Pamplona
eb6e235f1b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-02-05 16:52:57 -05:00
Vitor Pamplona
36c49b2171 Updates Zoomable and Zxing Qr Reader 2024-02-05 16:52:48 -05:00
Vitor Pamplona
5cd3096221 Merge pull request #761 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-05 12:43:57 -05:00
Crowdin Bot
5bdc59d0e3 New Crowdin translations by GitHub Action 2024-02-05 17:37:48 +00:00
Vitor Pamplona
be43f2025f v0.83.10 2024-02-05 12:35:42 -05:00
Vitor Pamplona
0bb39f91b9 README typos and formatting 2024-02-02 18:53:36 -05:00
Vitor Pamplona
63a7b44264 Adds crowdin address to the readme 2024-02-02 18:44:10 -05:00
Vitor Pamplona
b5341f67e0 Finally fixes: https://github.com/vitorpamplona/amethyst/issues/436 2024-02-02 18:23:44 -05:00
Vitor Pamplona
3a2045eccc Allows buttons in the New Post screen to scroll horizontally to partially fix https://github.com/vitorpamplona/amethyst/issues/723
We are waiting the default scrollbar implementation from Jetpack Compose
2024-02-02 17:39:48 -05:00
Vitor Pamplona
c78239a6b7 Fixes some connectivity issues by basing the timeout from the last connection tentative instead of the drop out time. 2024-02-02 16:08:36 -05:00
Vitor Pamplona
4be4306540 Merge pull request #759 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-02 10:27:22 -05:00
Crowdin Bot
48887f23c1 New Crowdin translations by GitHub Action 2024-02-02 14:37:01 +00:00
Vitor Pamplona
103809d35b Fixes the top padding of the quoted note in a post. 2024-02-02 09:35:17 -05:00
Vitor Pamplona
9d92976eb9 Optimizes memory use of the visual user and url tagger in new posts. 2024-02-02 09:30:36 -05:00
Vitor Pamplona
d3d19b497b Renames from NIP-29 to 92 2024-02-01 19:20:26 -05:00
Vitor Pamplona
316c32f076 v0.83.9 2024-02-01 18:38:25 -05:00
Vitor Pamplona
6f27201244 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-02-01 18:31:36 -05:00
Vitor Pamplona
e56377f8c3 Adds support for NIP-29 in public messages and new DMs. NIP-54 stays in NIP-54 2024-02-01 18:31:28 -05:00
Vitor Pamplona
44e04864ac Merge pull request #758 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-01 16:14:42 -05:00
Crowdin Bot
eea727570c New Crowdin translations by GitHub Action 2024-02-01 20:57:56 +00:00
Vitor Pamplona
54155a3c30 Includes the product in the first message of the marketplace. 2024-02-01 15:56:13 -05:00
Vitor Pamplona
34d373c293 Updates to Android Studio Hedgehog | 2023.1.1 Patch 2 2024-01-31 16:05:00 -05:00
Vitor Pamplona
d7563032c4 Merge pull request #756 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-01-31 14:37:59 -05:00
Crowdin Bot
ed75489975 New Crowdin translations by GitHub Action 2024-01-31 19:29:03 +00:00
Vitor Pamplona
145b0d8f0a v0.83.8 2024-01-31 14:26:58 -05:00
Vitor Pamplona
eae07e2fe0 Making sure Cancellation of coroutines stops long processes. 2024-01-30 17:33:44 -05:00
Vitor Pamplona
6b60b1434f Name refactoring 2024-01-30 13:52:02 -05:00
Vitor Pamplona
3ff4db264a Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-01-30 13:40:08 -05:00
Vitor Pamplona
21f042fa95 Fixes secondary buttons theme in the login and signup screens. 2024-01-30 13:38:41 -05:00
Vitor Pamplona
e26bbc2e61 Merge pull request #755 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-01-30 13:16:43 -05:00
Crowdin Bot
5135026e6a New Crowdin translations by GitHub Action 2024-01-30 18:12:06 +00:00
Vitor Pamplona
a72f5ae01f Removing unnecessary logs 2024-01-30 13:09:43 -05:00
Vitor Pamplona
f2ed2d94c4 Update README.md
Fixing Markdown
2024-01-30 12:01:23 -05:00
Vitor Pamplona
75d703ac0a Testing GitStr with a Readme patch 2024-01-30 11:54:01 -05:00
Vitor Pamplona
277bf0a603 Fixes misalignment of some links to users in the middle of the note. 2024-01-30 11:51:02 -05:00
Vitor Pamplona
1ac990b67c Set account to avoid lateinit property account has not been initialized 2024-01-30 11:20:25 -05:00
Vitor Pamplona
e59f676d47 updates secp256k1 2024-01-30 11:00:33 -05:00
Vitor Pamplona
312d82fdf6 moving the order or contributing 2024-01-30 09:47:31 -05:00
Vitor Pamplona
08659738bd Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-01-30 09:46:06 -05:00
Vitor Pamplona
f1b0958903 Merge pull request #754 from davotoula/fix_gr_locale
Correct GR locale config
2024-01-30 09:45:36 -05:00
Vitor Pamplona
998aa2aed1 Adding GitStr 2024-01-30 09:44:48 -05:00
David Kaspar
370a18f716 Correct GR locale config 2024-01-30 13:30:25 +00:00
Vitor Pamplona
6638defbb9 Fixes content sensitivity for NIP-54 images. 2024-01-29 18:26:25 -05:00
Vitor Pamplona
d88eb014b7 Adding the exception to the Zap error message. 2024-01-29 17:14:31 -05:00
Vitor Pamplona
14e9a91d40 Updates dependencies 2024-01-29 16:28:53 -05:00
Vitor Pamplona
3ed45f282c Fixing NIP number for wraps/seals 2024-01-29 11:19:56 -05:00
Vitor Pamplona
2ff090dac1 New NIP available 2024-01-29 11:03:15 -05:00
Vitor Pamplona
281d86b4bf Small refactoring on the HTTP client 2024-01-27 11:58:39 -05:00
Vitor Pamplona
504e55a93f Merge pull request #753 from greenart7c3/proxy_fix
Recreate http client when changing the proxy or timeout
2024-01-27 11:39:29 -05:00
greenart7c3
c7b2c20119 recreate httpclient when changing the proxy or timeout 2024-01-24 17:18:25 -03:00
Vitor Pamplona
3e102c064b Merge pull request #750 from greenart7c3/queries
remove the need for application package name in the androidManifest for the external signer
2024-01-23 09:22:58 -05:00
greenart7c3
e0ec8a741c remove the need for application package name in the androidManifest for the external signer 2024-01-22 11:29:10 -03:00
Vitor Pamplona
f1388b46d5 Merge pull request #746 from greenart7c3/main
add a copy url to the url preview card
2024-01-19 06:07:42 -05:00
greenart7c3
0f24e458d8 add a copy url to the url preview card 2024-01-19 07:18:25 -03:00
Vitor Pamplona
b8f3625954 Fixes remember of the wrong hashtag because it's a different post. 2024-01-18 15:57:21 -03:00
Vitor Pamplona
0babc46f7c Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-01-18 13:36:24 -03:00
Vitor Pamplona
6d368148cc makes the benchmark module profileable 2024-01-18 13:35:53 -03:00
Vitor Pamplona
3241f0ada1 formatting 2024-01-18 13:35:38 -03:00
Vitor Pamplona
49ad972af8 activates nostr.mom and nos.lol in the default relay set for global because of the Video feed. 2024-01-18 13:34:30 -03:00
Vitor Pamplona
e061575454 Removes unused imports 2024-01-18 13:33:48 -03:00
Vitor Pamplona
694642b42b Merge pull request #745 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-01-17 07:27:51 -05:00
Crowdin Bot
f4684ed078 New Crowdin translations by GitHub Action 2024-01-17 12:24:41 +00:00
Vitor Pamplona
db12ff0532 Merge pull request #744 from greenart7c3/reject_permission_amber
add support for always rejected permissions from external signer
2024-01-17 07:23:13 -05:00
greenart7c3
13d9c1ab10 add support for always rejected permissions from external signer 2024-01-17 09:05:24 -03:00
Vitor Pamplona
ac8f0e8600 v0.83.7 Testing new build tools 2024-01-11 12:33:55 -05:00
Vitor Pamplona
21472daab5 Uses new build tools to sign releases 2024-01-11 12:32:58 -05:00
Vitor Pamplona
c6a00386c8 adds distribution to github actions 2024-01-11 12:23:36 -05:00
Vitor Pamplona
9e3aa31c25 Updates Github actions scripts. 2024-01-11 12:06:45 -05:00
Vitor Pamplona
47987fac0f Merge pull request #743 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-01-11 10:52:13 -05:00
Crowdin Bot
46c782cf37 New Crowdin translations by GitHub Action 2024-01-11 15:51:24 +00:00
Vitor Pamplona
fd67f4875d Merge pull request #742 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-01-11 10:50:28 -05:00
Vitor Pamplona
4e2ff591e6 v0.83.6 2024-01-11 10:49:37 -05:00
Crowdin Bot
0e46145e2d New Crowdin translations by GitHub Action 2024-01-11 15:45:38 +00:00
Vitor Pamplona
383d859544 Fixes the BookmarkScreen update after adding and removing a bookmark to the list 2024-01-11 10:43:51 -05:00
Vitor Pamplona
81400eb863 Solving viewModel creation bug for Public Bookmarks 2024-01-11 10:36:52 -05:00
Vitor Pamplona
422446f53e Fixes bug when creating bookmarks for the first time. 2024-01-11 10:09:41 -05:00
Vitor Pamplona
f1e721f323 Fixes bug in checking bookmarks without running the onReady. 2024-01-11 10:09:19 -05:00
Vitor Pamplona
0fc72eb84d updates mockk 2024-01-11 09:43:11 -05:00
Vitor Pamplona
9234f4b7a8 Improves previews 2024-01-09 17:47:34 -05:00
Vitor Pamplona
0198d56099 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-01-09 16:35:04 -05:00
Vitor Pamplona
8f31c60cd1 Fixing some of the margins in the login screen. 2024-01-09 16:33:50 -05:00
Vitor Pamplona
7cb17954d9 Merge pull request #741 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-01-09 16:26:37 -05:00
Crowdin Bot
baf35b202f New Crowdin translations by GitHub Action 2024-01-09 21:25:47 +00:00
Vitor Pamplona
7b7e3624ac New Signup screen 2024-01-09 16:23:17 -05:00
Vitor Pamplona
d27b9ae5a8 Reducing the size of the following icon 2024-01-06 19:16:15 -05:00
Vitor Pamplona
4c48463091 v0.83.5 2024-01-06 16:53:18 -05:00
Vitor Pamplona
eb08d485e1 Fixing some of the tests due to the line breaks added by spotless 2024-01-06 15:11:12 -05:00
Vitor Pamplona
6e99e6f4a2 Makes sure the relay list contains unique urls. 2024-01-06 14:36:05 -05:00
Vitor Pamplona
d269e9e597 reduces some of the huge lines we have. 2024-01-06 11:59:35 -05:00
Vitor Pamplona
f8e7dd78d9 Reverts to the non-Google kotlin style. 2024-01-06 11:32:41 -05:00
Vitor Pamplona
0be1f89368 Fixes the lack of relay icons in the NIP44 messages 2024-01-06 11:24:52 -05:00
Vitor Pamplona
5c7e9258c1 Fixing some of the minification issues. 2024-01-06 11:13:57 -05:00
Vitor Pamplona
f877b6ff68 Creating the benchmark build type on the modules as well. 2024-01-06 10:53:54 -05:00
Vitor Pamplona
f5f43c5094 Creates a benchmark release type to avoid going back and forth with signing keys on the release 2024-01-06 10:48:11 -05:00
Vitor Pamplona
ec867ae8a2 Spotless fixes. 2024-01-06 10:44:32 -05:00
Vitor Pamplona
de61f7f383 Moves to spotless 2024-01-06 10:44:05 -05:00
Vitor Pamplona
ed6c1e62e4 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-01-05 16:46:30 -05:00
Vitor Pamplona
620b2bfa9f Improving the speed of contains 2024-01-05 16:46:18 -05:00
Vitor Pamplona
6715d48a9d Reverts kotlin version change 2024-01-05 16:45:58 -05:00
Vitor Pamplona
4291a58f85 Updates dependencies 2024-01-05 15:28:46 -05:00
Vitor Pamplona
00cffc8b6b fixing the benchmark for Robohash 2024-01-05 15:19:53 -05:00
Vitor Pamplona
7143d78663 Fixing the giftWrap test cases since the migration to v2 2024-01-05 15:18:46 -05:00
Vitor Pamplona
eaaa9a6446 Adjust position of the participant list in live streams. 2024-01-05 09:54:27 -05:00
Vitor Pamplona
41270c3a98 Merge pull request #739 from vuittont60/main
Fix some typos
2024-01-05 00:42:42 -05:00
vuittont60
a08a052b30 Fix typos 2024-01-05 11:27:52 +08:00
vuittont60
9403f12d2d Fix typos 2024-01-05 11:27:48 +08:00
Vitor Pamplona
89fb83cd9a Initializing the isHidden state for the note correctly 2023-12-29 19:44:48 -05:00
Vitor Pamplona
2355a95202 Avoiding the creation of modifiers. 2023-12-29 19:44:21 -05:00
Vitor Pamplona
53a320b4dc no need to remember the showProfilePicture state 2023-12-29 19:43:14 -05:00
Vitor Pamplona
2de3d19a34 Adds support for displaying video events. 2023-12-29 17:24:50 -05:00
Vitor Pamplona
57430c4366 Refactoring some of the old nomenclature of Kind 1 tags. 2023-12-29 14:25:14 -05:00
Vitor Pamplona
941ed77f22 Merge pull request #738 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-29 10:37:41 -05:00
Crowdin Bot
005b082873 New Crowdin translations by GitHub Action 2023-12-29 15:27:29 +00:00
Vitor Pamplona
9f219e31ab Merge pull request #737 from tyiu/nip-10-backwards-compatibility
Add write support for NIP-10 deprecated positional tags in text notes to maximize backwards compatibility
2023-12-29 10:26:15 -05:00
Terry Yiu
9101fb1e9e Add write support for NIP-10 deprecated positional tags in text notes to maximize backwards compatibility 2023-12-29 01:56:01 -05:00
Vitor Pamplona
c215beeb8e v0.83.4 2023-12-27 17:38:34 -05:00
Vitor Pamplona
20b730318f Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-27 17:00:48 -05:00
Vitor Pamplona
abef3d13f2 Improves the design of the discovery cards for Live Activity and Chats. 2023-12-27 17:00:41 -05:00
Vitor Pamplona
77eb066362 Fixes the loading of reactions and zaps to replaceable events. 2023-12-27 17:00:08 -05:00
Vitor Pamplona
ec4981852b Fixing the zap of live streams to go to the Host and not the pubkey in the event. 2023-12-27 16:59:28 -05:00
Vitor Pamplona
56c2c60c58 reorganizing Event Factory. 2023-12-27 16:58:56 -05:00
Vitor Pamplona
48aa9f950d adds support for NIP-75 events 2023-12-27 16:58:43 -05:00
Vitor Pamplona
37ae06347f Merge pull request #735 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-27 11:49:01 -05:00
Crowdin Bot
78616c07b1 New Crowdin translations by GitHub Action 2023-12-27 16:25:05 +00:00
Vitor Pamplona
73eff8a6c3 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-27 11:23:06 -05:00
Vitor Pamplona
4afb0027fb Initial support for separate vertical and horizontal videos. 2023-12-26 18:02:13 -05:00
Vitor Pamplona
1cedc8a6c8 Fixes reconnecting issue when DataSources were not active and become active later. 2023-12-26 18:01:31 -05:00
Vitor Pamplona
216ee59fae Merge pull request #732 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-26 15:22:19 -05:00
Crowdin Bot
adc4599adf New Crowdin translations by GitHub Action 2023-12-26 19:47:28 +00:00
Vitor Pamplona
8ed960530c Updates dependencies. 2023-12-26 14:43:01 -05:00
Vitor Pamplona
8f13a76870 v0.83.3 2023-12-23 17:45:56 -05:00
Vitor Pamplona
adaab4ced8 v0.83.2 2023-12-23 17:33:47 -05:00
Vitor Pamplona
5a2e149fa0 No need to remember simple state. 2023-12-23 17:28:04 -05:00
Vitor Pamplona
560c1f797c Performance: Reuse of modifiers in ReactionRow and Column 2023-12-23 16:28:02 -05:00
Vitor Pamplona
2d5be92f34 Performance: Improving the use or remember, less jittering when scrolling. 2023-12-23 15:28:04 -05:00
Vitor Pamplona
e9285fdac0 Simplifying the reaction row zap click. 2023-12-23 12:37:10 -05:00
Vitor Pamplona
4f12c127af Adds party, thinking and shock emoji to the default set 2023-12-23 12:04:41 -05:00
Vitor Pamplona
be7522382f Adds a log to debug notification receipts in production. 2023-12-23 11:38:11 -05:00
Vitor Pamplona
987fb02be1 Fixes relay icon size 2023-12-23 11:27:03 -05:00
Vitor Pamplona
bbd8a34f44 Initial refactoring of the elements within a post. 2023-12-22 19:44:01 -05:00
Vitor Pamplona
720ebfd0ea Avoids creating a new byte array at every call of conversation key. 2023-12-22 17:09:36 -05:00
Vitor Pamplona
159228972c Finishing the refactoring of Optout filters 2023-12-22 14:07:37 -05:00
Vitor Pamplona
2a65944eeb Removing unused parameter in this class. 2023-12-22 14:00:47 -05:00
Vitor Pamplona
e7902c4565 Testing new version 2023-12-22 13:49:29 -05:00
Vitor Pamplona
f12588ef7c Merge pull request #730 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-22 11:36:14 -05:00
Crowdin Bot
5fc197a40b New Crowdin translations by GitHub Action 2023-12-22 16:35:48 +00:00
Vitor Pamplona
64608f771e v0.83.0 2023-12-22 11:33:40 -05:00
Vitor Pamplona
a9cebaf3d9 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-22 11:33:13 -05:00
Vitor Pamplona
6c33dae498 Small refactoring before release. 2023-12-22 11:32:54 -05:00
Vitor Pamplona
e42d2dca04 Updates dependencies 2023-12-22 10:25:28 -05:00
Vitor Pamplona
35cd9cb6c5 Starts relay observers with a non null value 2023-12-22 09:15:21 -05:00
Vitor Pamplona
2ce16325a1 Improving the memory use of hashmaps 2023-12-21 15:26:01 -05:00
Vitor Pamplona
275aeb7833 Merge pull request #729 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-21 10:58:23 -05:00
Crowdin Bot
34737be5eb New Crowdin translations by GitHub Action 2023-12-21 15:57:34 +00:00
Vitor Pamplona
6fb5c873cc Removes signingConfig for debug 2023-12-21 10:52:46 -05:00
Vitor Pamplona
e0ffba5295 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-21 08:53:29 -05:00
Vitor Pamplona
82f03e0769 Merge pull request #728 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-20 19:20:00 -05:00
Vitor Pamplona
b223cc4028 updates okhttp 2023-12-20 19:19:58 -05:00
Crowdin Bot
550a9abdbb New Crowdin translations by GitHub Action 2023-12-20 23:09:16 +00:00
Vitor Pamplona
a121d1b301 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-20 18:07:46 -05:00
Vitor Pamplona
e794ff44a1 Moves to NIP-44v2 2023-12-20 18:07:37 -05:00
Vitor Pamplona
a22b6db130 Avoids Concurrent Modification Exception 2023-12-20 14:55:12 -05:00
Vitor Pamplona
1f7fc236db Fixes thread test 2023-12-20 12:47:28 -05:00
Vitor Pamplona
1edaea7905 Fix image uploader test 2023-12-20 11:46:44 -05:00
Vitor Pamplona
e8cae710c7 Merge pull request #726 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-19 16:46:57 -05:00
Crowdin Bot
489d92f9bb New Crowdin translations by GitHub Action 2023-12-19 21:29:59 +00:00
Vitor Pamplona
4d4c568afc Updates dependencies 2023-12-19 15:54:18 -05:00
Vitor Pamplona
e452c33a98 Uses primitive instead of object for Float 2023-12-19 15:54:10 -05:00
Vitor Pamplona
fdfd875580 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-19 15:53:43 -05:00
Vitor Pamplona
a6e8ee20ae Improves the performance of Robohash 2023-12-19 15:46:55 -05:00
Vitor Pamplona
065dba2431 Add a less memory intensive timeAgo calculation 2023-12-19 15:46:36 -05:00
Vitor Pamplona
5f581c2f5e Merge pull request #722 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-17 19:43:23 -05:00
Crowdin Bot
f7bfb68e7c New Crowdin translations by GitHub Action 2023-12-17 23:54:58 +00:00
Vitor Pamplona
2371e0678a Reusing SessionToken for all Playback connections 2023-12-17 18:44:18 -05:00
Vitor Pamplona
97c78aaa3c Moves the creation of the MediaItem to a thread. 2023-12-17 17:26:53 -05:00
Vitor Pamplona
9026c877ac - Adds i18n for error messages when uploading images.
- Forces nip95 to be under 80Kb
- Fixes bug that wouldn't show an error when uploading images in the reels page.
- Only saves default server if not NIP95
2023-12-17 17:26:17 -05:00
Vitor Pamplona
238ade9ccb Removes unused translation phrases. 2023-12-17 14:33:08 -05:00
Vitor Pamplona
016bd1ed81 Does apply the video's dimensions when the user has selected to not automatically start the playback 2023-12-17 14:30:04 -05:00
Vitor Pamplona
ed21d4cafb Detecting images by pinging the server instead of relying on the url. 2023-12-16 11:55:09 -05:00
Vitor Pamplona
ecc0f39f7d Moves relay modifiers to the app's theme 2023-12-16 11:42:11 -05:00
Vitor Pamplona
26846fa91a Refining recompositions of Route. 2023-12-15 22:21:43 -05:00
Vitor Pamplona
fe3a0568f2 Update README.md
Fixing the todo list in readme
2023-12-15 18:46:52 -05:00
Vitor Pamplona
56252d33f1 Updated Readme with the new refactorings from the NIP repo. 2023-12-15 18:38:06 -05:00
Vitor Pamplona
fccfb2aaba Support for NIP-31 alt tags 2023-12-15 18:19:37 -05:00
Vitor Pamplona
7b55b69c12 Adds a k tag to reactions 2023-12-15 18:00:26 -05:00
Vitor Pamplona
23fad2a818 removing some old time measuring code for additional performance. 2023-12-15 15:30:16 -05:00
Vitor Pamplona
2dc23b601a Performance: uses primitive instead of the wrapped object. 2023-12-15 14:58:57 -05:00
Vitor Pamplona
504980d263 Fixes bug with cropped joinToString 2023-12-14 20:17:38 -05:00
Vitor Pamplona
7cf7c9f082 Refactoring 2023-12-14 18:56:53 -05:00
Vitor Pamplona
dd8904de15 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-14 18:45:42 -05:00
Vitor Pamplona
d1c43f8147 Avoiding the creation of new sets when looping through maps in User and Note 2023-12-14 18:42:36 -05:00
Vitor Pamplona
68506fd6af Avoiding the creation of new sets when looping through maps in User and Note 2023-12-14 18:31:00 -05:00
Vitor Pamplona
b795ca06d1 Avoids creating another keySet. 2023-12-14 17:41:50 -05:00
Vitor Pamplona
8695acb742 No need to recreate Sets. 2023-12-14 17:41:25 -05:00
Vitor Pamplona
ed491f5e3a Avoids recreating the EOSE array when changing filters. 2023-12-14 15:52:52 -05:00
Vitor Pamplona
3a76c2a2de Minor refactoring 2023-12-14 15:02:49 -05:00
Vitor Pamplona
91b3b9e0e9 Moves to a less memory intensive way to send filters to the server. 2023-12-14 14:33:15 -05:00
Vitor Pamplona
fb280650a5 Ignore app insights local settings for Android studio 2023-12-14 11:03:33 -05:00
Vitor Pamplona
e4baebf2f9 Must be on main. 2023-12-14 11:02:55 -05:00
Vitor Pamplona
1a33da2537 Moving buildConfig to DSL 2023-12-14 10:45:14 -05:00
Vitor Pamplona
3734ee58ea Disabling log messages 2023-12-14 10:39:16 -05:00
Vitor Pamplona
9554b36f80 Updates secp256k1 lib 2023-12-14 10:38:41 -05:00
Vitor Pamplona
5225c6ad07 v0.82.3 2023-12-13 18:57:16 -05:00
Vitor Pamplona
c7be0e4a95 Memory improvements on relay lists and LiveData objects 2023-12-13 18:51:30 -05:00
Vitor Pamplona
d4f060d509 Improvements to the rendering of chat headers 2023-12-13 18:50:36 -05:00
Vitor Pamplona
9ba9ddee50 Using the new ChatHeader layout 2023-12-13 15:23:46 -05:00
Vitor Pamplona
7bbdd5e877 Avoid remembering Modifier 2023-12-13 15:23:32 -05:00
Vitor Pamplona
6cc65ce84a Avoid remembering Modifier 2023-12-13 15:22:23 -05:00
Vitor Pamplona
6c711f8acc v0.82.2 2023-12-13 12:10:06 -05:00
Vitor Pamplona
acefff80ee - Fixes the transition between short preview and full text on markdown.
- Adds NIP-44 metatags to markdown rendering.
- Adds background video rendering on markdown
- Refactors a few class names.
2023-12-13 12:09:59 -05:00
Vitor Pamplona
0e5b5d657f Calculate the text width of a space outside a composable. 2023-12-12 20:31:03 -05:00
Vitor Pamplona
c2532322fd Fixing the image link classification with uppercase extensions. 2023-12-12 20:29:28 -05:00
Vitor Pamplona
dc73aec334 No need to remember this. 2023-12-12 18:57:14 -05:00
Vitor Pamplona
aa909184fd Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-12 18:09:27 -05:00
Vitor Pamplona
205dd75e7e Update README.md
Aligning screenshots
2023-12-12 18:09:18 -05:00
Vitor Pamplona
be9100dc5e Minor refactoring. 2023-12-12 18:00:02 -05:00
Vitor Pamplona
b2e35cdb3e v0.82.1 2023-12-12 11:20:33 -05:00
Vitor Pamplona
827e168bf2 Fixing minification issue on release. 2023-12-12 11:16:46 -05:00
Vitor Pamplona
a14d4e6f4e v0.82.0 2023-12-12 09:32:59 -05:00
Vitor Pamplona
de61c0350e Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-12 09:31:25 -05:00
Vitor Pamplona
3e3c97076d Formatting 2023-12-12 09:31:14 -05:00
Vitor Pamplona
f62c986016 Improving sizes of blurhashes 2023-12-12 08:50:11 -05:00
Vitor Pamplona
04108e893d Merge pull request #720 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-12 08:44:58 -05:00
Crowdin Bot
042394796a New Crowdin translations by GitHub Action 2023-12-12 13:44:16 +00:00
Vitor Pamplona
bfce57f686 Improving the video switching from blurhash to video 2023-12-11 19:58:29 -05:00
Vitor Pamplona
5bb983ba5a Adding blurhash when images are not supposed to be downloaded. 2023-12-11 17:57:19 -05:00
Vitor Pamplona
debae02421 Improves the rendering of inline metadata 2023-12-11 16:22:01 -05:00
Vitor Pamplona
6fe93fdf33 Reverting image comment 2023-12-11 14:26:28 -05:00
Vitor Pamplona
3d225423ef Adds support for nip54 2023-12-11 12:51:53 -05:00
Vitor Pamplona
49e1da8812 Spacing 2023-12-11 12:50:44 -05:00
Vitor Pamplona
04addc4cba Adds support for the FileServers kind 2023-12-10 21:27:47 -05:00
Vitor Pamplona
c5cf2c5442 Adding the delete option for NIP-96 2023-12-10 21:21:00 -05:00
Vitor Pamplona
ddb7e6b7f2 Updates JNA 2023-12-10 18:32:47 -05:00
Vitor Pamplona
e5af34efca Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-08 22:08:30 -05:00
Vitor Pamplona
b3bdbbed98 - Migrates the old imageservers to NIP-96 for the main feed, chats, private messages, stories and user metadata uploads.
- Fixes hash calculation of the entire payload.
- Unifies uploads into NIP-94 images.
2023-12-08 22:08:19 -05:00
Vitor Pamplona
656b066d3a Merge pull request #719 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-08 19:50:21 -05:00
Crowdin Bot
4248fadc69 New Crowdin translations by GitHub Action 2023-12-09 00:45:17 +00:00
Vitor Pamplona
18281cbc49 Merge pull request #718 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-08 19:44:05 -05:00
Crowdin Bot
1d67af328a New Crowdin translations by GitHub Action 2023-12-08 16:23:27 +00:00
Vitor Pamplona
72b4f6acef Merge pull request #715 from davotoula/tr
Correct spelling of localised string reference
2023-12-08 11:21:52 -05:00
Vitor Pamplona
26273270ba Merge branch 'main' into tr 2023-12-08 11:21:46 -05:00
Vitor Pamplona
db3824c3c7 New Cashu Redeeming card. 2023-12-06 18:43:54 -05:00
Vitor Pamplona
d6de1d03d8 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-06 15:40:05 -05:00
Vitor Pamplona
8e4b7c66b5 Adds a UserAgent to all HTTP requests. 2023-12-06 15:40:01 -05:00
Vitor Pamplona
45c750069a Refactoring the update of channel information. 2023-12-06 15:38:31 -05:00
Vitor Pamplona
a411a72024 Merge pull request #716 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-06 14:59:12 -05:00
Crowdin Bot
219d163680 New Crowdin translations by GitHub Action 2023-12-06 19:38:31 +00:00
Vitor Pamplona
a4c9f42205 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-06 14:36:21 -05:00
Vitor Pamplona
a8936c54d8 Adds a Marketplace tab to Discovery 2023-12-06 13:40:54 -05:00
David Kaspar
74875bb564 Correct spelling of localised string reference 2023-12-06 15:07:52 +01:00
Vitor Pamplona
61d412d941 Merge pull request #714 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-06 08:14:16 -05:00
Crowdin Bot
5bb1e47edf New Crowdin translations by GitHub Action 2023-12-06 13:13:27 +00:00
Vitor Pamplona
b023614b5f Merge pull request #713 from davotoula/tr
Updated translations for cs/de/sv/pt
2023-12-06 08:11:39 -05:00
David Kaspar
7245718003 Updated translations for cs/de/sv/pt 2023-12-06 12:15:02 +01:00
David Kaspar
ba1f98d930 Updated translations for cs/de/sv/pt 2023-12-06 12:08:20 +01:00
David Kaspar
ceeabffcb7 Correct spelling of localised string reference 2023-12-06 12:04:50 +01:00
David Kaspar
2a943493b3 Updated translations for cs/de/sv/pt 2023-12-06 11:21:41 +01:00
Vitor Pamplona
332a2f41b6 Moves the relay observer and reconnection to Service Manager's scope. 2023-12-05 21:48:43 -05:00
Vitor Pamplona
ce2cfb7c86 fixes bug when updating relay list that used keep the previous list. 2023-12-05 21:48:05 -05:00
Vitor Pamplona
41ef1abf0e Fixes deletion of replaceable events. 2023-12-05 21:47:20 -05:00
Vitor Pamplona
fce27c3c55 Presenting better error messages when the image upload fails. 2023-12-05 21:46:35 -05:00
Vitor Pamplona
6662966ab3 Fixing crash when image is an SVG and tries to compress. 2023-12-05 21:45:56 -05:00
Vitor Pamplona
f458f00edc 1. Adds Classified creation from Amethyst.
2. Adds relay information for Replaceable events.
2023-12-05 21:44:49 -05:00
Vitor Pamplona
8f1fbe10e9 Merge pull request #712 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-05 11:41:45 -05:00
Crowdin Bot
69efa78fbc New Crowdin translations by GitHub Action 2023-12-05 16:16:54 +00:00
Vitor Pamplona
64a07d8760 Updates to fix Crash due to malformed video formats. 2023-12-05 09:52:15 -05:00
Vitor Pamplona
cb672d92d0 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-04 13:25:54 -05:00
Vitor Pamplona
95bcbfdded Reusing common icons for Cashu's Redemption 2023-12-04 13:23:19 -05:00
Vitor Pamplona
ac269727bc Merge pull request #711 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-04 11:57:45 -05:00
Crowdin Bot
b1a7d18c0a New Crowdin translations by GitHub Action 2023-12-04 15:53:21 +00:00
Vitor Pamplona
619b59357b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-04 10:52:23 -05:00
Vitor Pamplona
86dcc3ccd9 Merge pull request #710 from believethehype/main
add a button to cashu preview to redeem on external wallet
2023-12-04 10:51:41 -05:00
Vitor Pamplona
6772360ae7 Fixes bottom bar appearing in chats when the keyboard is open. 2023-12-04 10:48:29 -05:00
Vitor Pamplona
05cbbd79cb Uses nostr.wine instead of filter.nostr.wine as a search relay. 2023-12-04 10:47:38 -05:00
Believethehype
a80c03a2d2 Update CashuRedeem.kt 2023-12-04 16:20:40 +01:00
Believethehype
6c3abe17ed add a button to cashu preview to redeem on external wallet, additional to melting to ln 2023-12-03 15:02:22 +01:00
Vitor Pamplona
7cd09261de Optimizes the rendering of the side bar. 2023-12-02 17:23:15 -05:00
Vitor Pamplona
6ef1bee8a0 Updates EOSE in the same thread of the new event to reduce the amount of coroutine launches. 2023-12-02 14:54:34 -05:00
Vitor Pamplona
66b8b04db3 Updates dependencies. 2023-12-02 14:14:15 -05:00
Vitor Pamplona
3b06abb2bc Upgrades junit benchmark 2023-12-02 14:12:10 -05:00
Vitor Pamplona
138fafa204 Updates gradle 2023-12-02 14:11:19 -05:00
Vitor Pamplona
9cbdbb8592 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-02 13:56:53 -05:00
Vitor Pamplona
1c0ec93cbb Testing the use of just one HttpClient for the entire app. 2023-12-02 13:55:55 -05:00
Vitor Pamplona
c6fa2b5f80 Merge pull request #708 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-02 13:46:20 -05:00
Crowdin Bot
dd55ad18e3 New Crowdin translations by GitHub Action 2023-12-02 18:39:06 +00:00
Vitor Pamplona
284da365a1 Merge pull request #707 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-02 13:37:59 -05:00
Crowdin Bot
7a06c2e9c4 New Crowdin translations by GitHub Action 2023-12-02 16:21:46 +00:00
Vitor Pamplona
61449774a1 Improves the Redeeming feedback
Improves fee calculation from the mint
2023-12-02 11:18:01 -05:00
Vitor Pamplona
81a3baa41c Merge pull request #706 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-01 09:35:18 -05:00
Crowdin Bot
4b7e58dd1c New Crowdin translations by GitHub Action 2023-12-01 14:06:41 +00:00
Vitor Pamplona
14ce42401b Merge pull request #705 from greenart7c3/main
fix zap splits when using amber with intents
2023-12-01 09:05:26 -05:00
greenart7c3
ecb8344287 fix zap splits when using amber with intents 2023-12-01 10:52:15 -03:00
Vitor Pamplona
9bc5549d76 v0.81.5 2023-11-30 17:28:58 -05:00
Vitor Pamplona
8ac8894ccd Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-11-30 17:27:03 -05:00
Vitor Pamplona
efb9814f1b Fixes blocked lists on Videos. 2023-11-30 17:18:41 -05:00
Vitor Pamplona
3582f587b3 Merge pull request #704 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-30 17:05:19 -05:00
Crowdin Bot
4fbc9f6d53 New Crowdin translations by GitHub Action 2023-11-30 21:26:37 +00:00
Vitor Pamplona
e9ec1c45a0 v0.81.4 2023-11-30 16:25:07 -05:00
Vitor Pamplona
b2b7cba352 Loads basic information for all the accounts in the app. 2023-11-30 16:24:53 -05:00
Vitor Pamplona
393fbd0359 clean up 2023-11-30 15:17:21 -05:00
Vitor Pamplona
47ae42e3fe Avoiding bug when creating accounts in the background and trying to create the live set 2023-11-30 15:16:21 -05:00
Vitor Pamplona
5db3ede09e Makes Client thread safe. Forcing the disconnect of an old relay list before connecting to a new one. 2023-11-30 15:03:58 -05:00
Vitor Pamplona
eca420d789 Fixing the scope in HomeDataSource. 2023-11-30 15:02:16 -05:00
Vitor Pamplona
5347373cde Avoids sending filters with empty lists. 2023-11-30 14:53:59 -05:00
Vitor Pamplona
05596cb9bf Forces websocket closure onFailure 2023-11-30 14:51:55 -05:00
Vitor Pamplona
3d1310295b No need to call two launch functions 2023-11-30 14:51:13 -05:00
Vitor Pamplona
35c780f81b Avoids bug on creating the live set in the IO thread. 2023-11-30 14:50:24 -05:00
Vitor Pamplona
b021920eaa Emitting an empty list if it cannot decrypt it 2023-11-30 11:22:42 -05:00
Vitor Pamplona
2b27ac3aec Cache zap calculations in notification cards. 2023-11-30 11:22:06 -05:00
Vitor Pamplona
c692c9125b Fixes null list names showing before the list is loaded 2023-11-30 10:33:48 -05:00
Vitor Pamplona
7322fd7aae Fixes hidden buttons in the Chat creation screen. 2023-11-30 10:30:12 -05:00
Vitor Pamplona
07a92cd70d Fixes all or nothing decryption of Zaps failing in a few cases. Now it has a fallback when decryption fails. 2023-11-30 10:15:36 -05:00
Vitor Pamplona
21a797be9d v0.81.3 2023-11-29 17:34:15 -05:00
Vitor Pamplona
fe49cbabce Improves the rendering of LN Invoice Previews. 2023-11-29 17:22:04 -05:00
Vitor Pamplona
9f674a0088 Adds flexibility on timeouts between WIFI and mobile connections. 2023-11-29 17:21:40 -05:00
Vitor Pamplona
b44e94cb16 Adds a test case for urls ending with a period. 2023-11-29 16:50:36 -05:00
Vitor Pamplona
a8da645be3 Increasing timeout to account for slower connections. 2023-11-29 16:50:11 -05:00
Vitor Pamplona
c6e6bf6032 Loads the user profile before building the live set. 2023-11-29 16:14:02 -05:00
Vitor Pamplona
7596f2a43e v0.81.2 2023-11-29 15:13:19 -05:00
Vitor Pamplona
6b7bb209d4 Refinements to ShopStr's event rendering. 2023-11-29 14:56:42 -05:00
Vitor Pamplona
fcdb863c2b Merge pull request #701 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-29 10:54:19 -05:00
Crowdin Bot
4e5a9f7366 New Crowdin translations by GitHub Action 2023-11-29 15:52:07 +00:00
Vitor Pamplona
758f3636f3 Merge pull request #700 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-29 10:50:54 -05:00
Vitor Pamplona
7dafb8317a version 0.81.1 2023-11-29 10:50:22 -05:00
Crowdin Bot
21a5827719 New Crowdin translations by GitHub Action 2023-11-29 15:40:53 +00:00
Vitor Pamplona
b9486eb3e6 Adds back button to the top of the Nav bar of the Thread view. 2023-11-29 10:38:31 -05:00
Vitor Pamplona
dbbfaae02c Fixes video release coroutine being killed by Android 2023-11-29 10:17:57 -05:00
Vitor Pamplona
44b25b7c7c Refines the EOSE strategy to make sure relays that have not replied have a chance to 2023-11-29 10:06:28 -05:00
Vitor Pamplona
0dabf3f2d3 Scopes the service manager into the AccountState ViewModel 2023-11-29 09:14:04 -05:00
Vitor Pamplona
a85ea70e4f Avoids requesting an empty list of chats 2023-11-28 22:23:34 -05:00
Vitor Pamplona
a02519b021 Forces close to happen in an IO thread 2023-11-28 22:22:28 -05:00
Vitor Pamplona
04e70a7886 Make the afterEOSE logic subscription dependent. 2023-11-28 22:22:08 -05:00
Vitor Pamplona
3bfaef2b45 Using the entire event id to filter threads. 2023-11-28 22:17:24 -05:00
Vitor Pamplona
e992e72f4d Making sure the service manager stops the relay subscriptions before disconnecting. 2023-11-28 22:17:01 -05:00
Vitor Pamplona
1c545ee33a Forces the closure. The method close wasn't closing all connections. 2023-11-28 18:00:44 -05:00
Vitor Pamplona
ab93a39ad6 Avoids race conditions when the data source is supposed to have stopped 2023-11-28 18:00:22 -05:00
Vitor Pamplona
7f25e1c434 Limits Notification downloads to 1 week. 2023-11-28 17:59:28 -05:00
Vitor Pamplona
67b7bd3258 Protection against NPEs 2023-11-28 16:36:27 -05:00
Vitor Pamplona
e2aaa8efe0 Migrates from PAY to NOTIFY as per https://github.com/nostr-protocol/nips/pull/901 2023-11-28 16:07:24 -05:00
Vitor Pamplona
5356d869fd Reduces the amount of wasted memory by using Array<Array<List>> as opposed to List<List<String>> for tags. 2023-11-28 15:00:41 -05:00
Vitor Pamplona
d87542a949 Reduces JC usage by creating arrays with the exact amount of bytes when deserializing events. 2023-11-28 09:37:00 -05:00
Vitor Pamplona
98d29ac6dc v0.81.0 2023-11-28 09:05:25 -05:00
Vitor Pamplona
532048c35e Merge pull request #699 from greenart7c3/main
fix crash tryng to decrypt/parse gossip
2023-11-27 12:49:15 -05:00
greenart7c3
386960dc62 fix crash tryng to decrypt/parse gossip 2023-11-27 14:28:15 -03:00
Vitor Pamplona
ae3cc5da2a Fixing benchmark tests after the refactoring. 2023-11-26 21:10:15 -05:00
Vitor Pamplona
34c2ba4922 Typo 2023-11-26 18:30:29 -05:00
Vitor Pamplona
074a7d41fd Adding payment requests from nostr.wine 2023-11-26 18:00:08 -05:00
Vitor Pamplona
7d4fbc3e04 Reducing the precision to load images. 2023-11-26 17:59:10 -05:00
Vitor Pamplona
a2d38381b1 Runs the translation as a UI Scope instead of ViewModel's 2023-11-26 15:02:10 -05:00
Vitor Pamplona
19fb129f7c Fixing test cases after the mega refactoring 2023-11-26 14:59:00 -05:00
Vitor Pamplona
5bfc53136b updates jackson. 2023-11-25 19:49:51 -05:00
Vitor Pamplona
1677606b15 updating via AGP 2023-11-25 19:46:49 -05:00
Vitor Pamplona
8c10173b68 Merge pull request #698 from greenart7c3/main
fix wrong pubkey in encrypt/decrypt nip44 with external signer
2023-11-25 16:31:42 -05:00
greenart7c3
285ae056a2 fix wrong pubkey in encrypt and decrypt nip44 external signer 2023-11-25 14:28:48 -03:00
Vitor Pamplona
8cdd3be085 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-11-24 12:28:01 -05:00
Vitor Pamplona
8f586780a1 Slightly improving performance 2023-11-24 12:27:52 -05:00
Vitor Pamplona
dc3c730e3b adding some labels to crossfades 2023-11-24 11:54:35 -05:00
Vitor Pamplona
df823102da Typo 2023-11-24 11:53:51 -05:00
Vitor Pamplona
1ab857d72b Merge pull request #697 from greenart7c3/main
support for sending/receiving multiple events from external signer
2023-11-24 10:41:06 -05:00
Vitor Pamplona
78863b26d0 Merge pull request #696 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-24 10:40:16 -05:00
Crowdin Bot
0f74bc7b74 New Crowdin translations by GitHub Action 2023-11-24 15:37:59 +00:00
Vitor Pamplona
e7893cacd5 Updating coil 2023-11-24 10:35:02 -05:00
greenart7c3
8a18781048 support for sending/receiving multiple events from external signer 2023-11-24 12:33:10 -03:00
Vitor Pamplona
ad52855f5a Merge pull request #695 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-23 21:59:47 -05:00
Crowdin Bot
50fbde17df New Crowdin translations by GitHub Action 2023-11-24 02:29:22 +00:00
Vitor Pamplona
c6d47ca9c6 Merge pull request #694 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-23 21:26:55 -05:00
Crowdin Bot
072b14bdda New Crowdin translations by GitHub Action 2023-11-23 15:49:32 +00:00
Vitor Pamplona
70964e680a Migrates to MuteList 10000 2023-11-23 10:46:56 -05:00
Vitor Pamplona
954330064d Trying to save from crashes on the signer 2023-11-22 17:04:57 -05:00
Vitor Pamplona
3666a0b980 Fixes race conditions when opening videos. 2023-11-22 16:29:05 -05:00
Vitor Pamplona
61f52ef62e Trimming memory onStop 2023-11-22 16:10:55 -05:00
Vitor Pamplona
74d66f2035 Fixes logging for new events. 2023-11-22 14:39:01 -05:00
Vitor Pamplona
0de4cd5c18 Adding a geohash mipmap to events. 2023-11-22 12:48:19 -05:00
Vitor Pamplona
d1bef11f88 Fixes spacing when drawing POW and Geolocation at the same time. 2023-11-22 12:37:02 -05:00
Vitor Pamplona
3253b8528f Fix URL Preview card when websites blend OG specs 2023-11-22 12:15:09 -05:00
Vitor Pamplona
81661c003a Fixes the writting of nostr addresses in the text with additional words connected to them without a space. It adds a space if the first char is in the bech alphabet. 2023-11-22 11:40:12 -05:00
Vitor Pamplona
fbe395f071 Adds logs for lifetime events. 2023-11-22 11:00:52 -05:00
Vitor Pamplona
d5cf64f048 Removes log message 2023-11-22 09:54:36 -05:00
Vitor Pamplona
7212d566c9 Fixes the blue notification dot appearing when the user receives a notification from a blocked account before downloading the blocklist 2023-11-22 09:10:15 -05:00
Vitor Pamplona
17490e948c Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-11-22 08:44:53 -05:00
Vitor Pamplona
b392ea0dca Merge pull request #691 from greenart7c3/test_external_signer
Fixes/improvements to external signer
2023-11-22 08:44:46 -05:00
greenart7c3
9fc0c37845 fix relay reconnecting when returning from external signer 2023-11-22 08:33:41 -03:00
greenart7c3
6db031b208 fix sending zaps using amber, send some default permissions on login, save the signer package name 2023-11-22 08:33:41 -03:00
Vitor Pamplona
7246a545af Makes sure it doesn't crash on starting. 2023-11-21 14:17:32 -05:00
Vitor Pamplona
c8365439ee Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-11-21 13:29:51 -05:00
Vitor Pamplona
003d6bf018 Updating dependencies. 2023-11-21 13:29:48 -05:00
Vitor Pamplona
060286c292 Merge pull request #692 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-21 10:30:16 -05:00
Crowdin Bot
270935a30a New Crowdin translations by GitHub Action 2023-11-21 15:14:23 +00:00
Vitor Pamplona
05e9a1ac3f Fixes issue with cache of disappearing User Zaps in Notifications 2023-11-20 19:03:34 -05:00
Vitor Pamplona
9acad8b3f5 Narrowing the redownloads of reactions down. 2023-11-20 18:58:25 -05:00
Vitor Pamplona
5a9d6929e5 Make local image behave as remove images. 2023-11-20 15:52:18 -05:00
Vitor Pamplona
e45f02c110 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-11-19 17:58:27 -05:00
Vitor Pamplona
4f1ae72ab5 Merge pull request #690 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-19 17:47:22 -05:00
Vitor Pamplona
d4ed6661dd Fixes single click login screen 2023-11-19 17:45:00 -05:00
Crowdin Bot
2976349114 New Crowdin translations by GitHub Action 2023-11-19 22:31:15 +00:00
Vitor Pamplona
b1cfa2219d Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-11-19 17:29:20 -05:00
Vitor Pamplona
df9b764c1d Massive refactoring to unify internal signer and the Amber signer. 2023-11-19 17:28:17 -05:00
Vitor Pamplona
69941002df Adds JUnitRunner after upstream changed packages. 2023-11-18 15:26:48 -05:00
Vitor Pamplona
afc6a91c6b Merge pull request #685 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-10 09:12:56 -05:00
Crowdin Bot
c8b9858e4d New Crowdin translations by GitHub Action 2023-11-10 14:07:24 +00:00
Vitor Pamplona
c4ea6f3619 Merge pull request #686 from vicariousdrama/20231110-unabbrev-amounts-up-to-4-digit
Display amounts up to 4 digits without abbreviation
2023-11-10 09:05:43 -05:00
vicariousdrama
872d70c175 Allow amounts up to 4 digits without abbreviation 2023-11-10 07:21:06 +00:00
Vitor Pamplona
5ad6a2c70b Moves service manager to an Activity scoped class. 2023-11-09 15:25:59 -05:00
Vitor Pamplona
408b6793c0 Moves image cache to the Application class 2023-11-09 15:25:27 -05:00
Vitor Pamplona
43d03bc109 Caching total Zaps 2023-11-09 14:26:11 -05:00
Vitor Pamplona
9377c902a1 Moves creation of the LiveData set to a Synchronized block to avoid double creations without destruction. 2023-11-09 14:18:54 -05:00
Vitor Pamplona
57d97447f4 Fix error message when parsing LnInvoice 2023-11-09 14:07:50 -05:00
Vitor Pamplona
5493ce0dcf Updating dependencies 2023-11-09 12:02:28 -05:00
Vitor Pamplona
499149b7d9 Speeds up LNInvoice parsing 2023-11-09 10:56:37 -05:00
Vitor Pamplona
3fd0c61c0e Updating to support all crowdin languages 2023-11-08 17:08:23 -05:00
Vitor Pamplona
1d62d8ed73 v0.80.7 2023-11-08 14:41:07 -05:00
Vitor Pamplona
2c3a7daa65 Fixes bug when the app calls this method directly, bypassing the other checks in AccountViewModel 2023-11-08 14:32:54 -05:00
Vitor Pamplona
1771b6e667 v0.80.6 2023-11-07 17:39:18 -05:00
Vitor Pamplona
853dcb2127 minimizes race condition when pausing and restarting relay connections 2023-11-07 17:38:59 -05:00
Vitor Pamplona
b25f346370 v0.80.5 2023-11-07 16:44:49 -05:00
Vitor Pamplona
edd13fbe5b Uses the indexing operator 2023-11-07 16:31:36 -05:00
Vitor Pamplona
92c72e5a7b Merge pull request #669 from VictorieeMan/patch-1
changing externa sharing service for text notes to njump.me
2023-11-07 10:33:24 -05:00
Vitor Pamplona
e8e676afd5 Merge pull request #683 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-07 09:31:23 -05:00
Crowdin Bot
bb16ee7f0f New Crowdin translations by GitHub Action 2023-11-07 14:25:33 +00:00
Vitor Pamplona
4eb1c101b9 Merge pull request #682 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-07 09:23:49 -05:00
Crowdin Bot
eb67b6e6cf New Crowdin translations by GitHub Action 2023-11-07 02:19:53 +00:00
Vitor Pamplona
29541bd1e3 Merge pull request #681 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-06 21:18:43 -05:00
Crowdin Bot
08f94e9199 New Crowdin translations by GitHub Action 2023-11-07 02:18:14 +00:00
Vitor Pamplona
a7c7773a81 Merge pull request #680 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-06 21:16:55 -05:00
Crowdin Bot
75f2d5710a New Crowdin translations by GitHub Action 2023-11-07 02:15:40 +00:00
Vitor Pamplona
2bff9fe891 Merge pull request #679 from vitorpamplona/l10n_crowdin_translations
New Greek Translations
2023-11-06 21:14:07 -05:00
Vitor Pamplona
4d2c405aea Removes unnecessary methods 2023-11-06 20:45:53 -05:00
Crowdin Bot
ca24afd23e New Crowdin translations by GitHub Action 2023-11-07 01:15:03 +00:00
Vitor Pamplona
47e124c7fc Correctly highliting a notification when dragged 2023-11-06 20:13:00 -05:00
Vitor Pamplona
885aded701 Merge pull request #678 from vitorpamplona/l10n_crowdin_translations
New Spanish Translations
2023-11-06 16:38:32 -05:00
Crowdin Bot
e0729b7236 New Crowdin translations by GitHub Action 2023-11-06 21:29:19 +00:00
Vitor Pamplona
a058b75ee4 Refining the padding of chat messages and reaction row 2023-11-06 16:26:38 -05:00
Vitor Pamplona
0a5e84fd3c Refining layout of URL Previews 2023-11-05 11:27:30 -05:00
Vitor Pamplona
6fa36255b2 Refining the layout of Author Pictures 2023-11-05 11:27:15 -05:00
Vitor Pamplona
3de52a6c0d Simplifying Compose stack 2023-11-04 16:48:29 -04:00
Vitor Pamplona
6d17c8d79f Fixing translations of the same term but with different case. 2023-11-04 11:29:47 -04:00
Vitor Pamplona
26c4677b75 Forces reconnection when a new service is available. 2023-11-04 11:23:37 -04:00
Vitor Pamplona
c5882a9f05 Migrates the check if stream is online to a single compose object. 2023-11-04 11:23:18 -04:00
Vitor Pamplona
f99dc471a1 Merge pull request #676 from vitorpamplona/l10n_crowdin_translations
Updated Translations for Arabic, Greek, Spanish, Indonesian and Vietnamese
2023-11-03 17:25:13 -04:00
Crowdin Bot
25e31b00d2 New Crowdin translations by GitHub Action 2023-11-03 21:16:29 +00:00
Vitor Pamplona
c1a59a1d21 Improve position of video controlling buttons when top bars are present. 2023-11-03 17:14:41 -04:00
Vitor Pamplona
e0d2a71b47 Remove a compose recomposition between the started state and the isOnline state that is already cached. 2023-11-03 17:14:10 -04:00
Vitor Pamplona
ef90b82396 Updates Kotlin compiler version 2023-11-03 17:11:16 -04:00
VictorieeMan
1f28d3f32f changing externa sharing service for text notes to njump.me
Changed "https://snort.social/e/" to "https://njump.me/"

Benefits of this change are the link previews that njump.me generates for shared content. The snort social links are just the url root and a long nevent, making different shares difficult to tell apart, and a bit scary to click on for some.

More benefits of njump.me listed on https://njump.me/, hosted by fiatjaf. The included nostr onboarding paired with client agnosticism is also beneficial.
2023-10-28 12:12:31 +02:00
501 changed files with 58364 additions and 34357 deletions

14
.editorconfig Normal file
View File

@@ -0,0 +1,14 @@
root = true
[*]
charset = utf-8
trim_trailing_whitespace = true
[*.{kt,kts}]
max_line_length = 5000
ij_kotlin_allow_trailing_comma=true
ij_kotlin_allow_trailing_comma_on_call_site=true
ktlint_function_naming_ignore_when_annotated_with=Composable, Test
[*.gradle]
indent_size = 4

View File

@@ -12,15 +12,17 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@v2
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v1
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: 17
- name: Cache gradle
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
@@ -28,7 +30,7 @@ jobs:
${{ runner.os }}-gradle-
- name: Linter (gradle)
run: ./gradlew ktlintCheck
run: ./gradlew spotlessCheck
- name: Test (gradle)
run: ./gradlew test --no-daemon

View File

@@ -10,15 +10,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v1
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: 17
- name: Cache gradle
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
@@ -36,6 +37,9 @@ jobs:
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "34.0.0"
- name: Sign AAB (F-Droid)
uses: r0adkll/sign-android-release@v1
@@ -45,6 +49,9 @@ jobs:
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "34.0.0"
- name: Build APK
run: ./gradlew assembleRelease --stacktrace
@@ -57,6 +64,9 @@ jobs:
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "34.0.0"
- name: Sign APK (F-Droid)
uses: r0adkll/sign-android-release@v1
@@ -66,6 +76,9 @@ jobs:
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "34.0.0"
- name: Create Release
id: create_release

View File

@@ -10,8 +10,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Checkout code
uses: actions/checkout@v4
- name: crowdin action
uses: crowdin/github-action@v1

3
.gitignore vendored
View File

@@ -7,6 +7,9 @@
/.idea/assetWizardSettings.xml
/.idea/androidTestResultsUserPreferences.xml
/.idea/deploymentTargetDropDown.xml
/.idea/appInsightsSettings.xml
/.idea/ktlint-plugin.xml
/.idea/ktfmt.xml
.DS_Store
/build
/captures

View File

@@ -3,30 +3,39 @@
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" 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" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

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="1.9.0" />
<option name="version" value="1.9.22" />
</component>
</project>

175
README.md
View File

@@ -2,7 +2,7 @@
<img align="right" src="./docs/screenshots/home.png" data-canonical-src="./docs/screenshots/home.png" width="350px"/>
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
[<img src="./docs/design/obtainium.png"
alt="Get it on Obtaininum"
@@ -18,91 +18,94 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
# Current Features
- [x] Event Builders / WebSocket Subscriptions (NIP-01)
- [x] Home Feed
- [x] Notifications Feed
- [x] Global Feed
- [x] Replies and mentions (NIP-10)
- [x] Reactions (NIP-25)
- [x] Reposts (NIP-18)
- [x] Image/Video/Url/LnInvoice Previews
- [x] View Threads
- [x] Events / Relay Subscriptions (NIP-01)
- [x] Follow List (NIP-02)
- [ ] OpenTimestamps Attestations (NIP-03)
- [x] Private Messages (NIP-04)
- [x] User Profiles (edit/follow/unfollow - NIP-02)
- [x] Public Chats (NIP-28)
- [x] Bech Encoding support (NIP-19)
- [x] Notification Dots
- [x] Reporting and Hide User capability (NIP-56)
- [x] Automatic Translations
- [x] Relay Sets (home, dms, public chats, global)
- [x] User/Note Tagging (NIP-08, NIP-10)
- [x] Lightning Tips
- [x] Zaps (private, public, anon, non-zap) (NIP-57)
- [x] URI Support (NIP-21)
- [x] Created_at Limits (NIP-22)
- [x] DNS Address (NIP-05)
- [ ] Mnemonic seed phrase (NIP-06)
- [ ] WebBrowser Signer (NIP-07, Not applicable)
- [x] Old-style mentions (NIP-08)
- [x] Event Deletion (NIP-09)
- [x] Nostr Address (NIP-05)
- [x] Replies, mentions, Threads, and Notifications (NIP-10)
- [x] Relay Information Document (NIP-11)
- [x] Generic Tag Queries (NIP-12)
- [x] Proof of Work Display (NIP-13)
- [ ] Proof of Work Calculations (NIP-13)
- [x] Events with a Subject (NIP-14)
- [ ] Marketplace (NIP-15)
- [x] Event Treatment (NIP-16)
- [x] Image/Video/Url/LnInvoice Previews
- [x] Reposts, Quotes, Generic Reposts (NIP-18)
- [x] Bech Encoding support (NIP-19)
- [x] Command Results (NIP-20)
- [x] URI Support (NIP-21)
- [x] Long-form Content (NIP-23)
- [x] User Profile Fields / Relay list (NIP-24)
- [x] Reactions (NIP-25)
- [ ] Delegated Event Signing (NIP-26, Will not implement)
- [x] Text Note References (NIP-27)
- [x] Public Chats (NIP-28)
- [x] Custom Emoji (NIP-30)
- [x] Event kind summaries (NIP-31)
- [ ] Labeling (NIP-32)
- [x] Parameterized Replaceable Events (NIP-33)
- [x] Online Relay Search (NIP-50)
- [x] Internationalization
- [x] Badges (NIP-58)
- [x] Hashtag Following and Custom Hashtags
- [x] Polls (NIP-69)
- [x] Verifiable static content in URLs (NIP-94)
- [x] Login with QR
- [x] Sensitive Content (NIP-36)
- [x] User Status Event (NIP-38)
- [x] External Identities (NIP-39)
- [x] Expiration Support (NIP-40)
- [x] Relay Authentication (NIP-42)
- [ ] Event Counts (NIP-45, Will not implement)
- [ ] Nostr Connect (NIP-46)
- [x] Wallet Connect API (NIP-47)
- [x] Accessible uploads
- [ ] Proxy Tags (NIP-48, Not applicable)
- [ ] Private key encryption for import/export (NIP-49)
- [x] Online Relay Search (NIP-50)
- [x] Lists (NIP-51)
- [ ] Calendar Events (NIP-52)
- [x] Live Activities & Live Chats (NIP-53)
- [x] Inline Metadata (NIP-55 - Draft)
- [x] Reporting (NIP-56)
- [x] Lightning Tips
- [x] Zaps (NIP-57)
- [x] Private Zaps
- [x] Zap Splits (NIP-57)
- [x] Gift Wraps & Seals (NIP-59)
- [x] Zapraiser (NIP-TBD)
- [x] Badges (NIP-58)
- [ ] Relay List Metadata (NIP-65)
- [x] Polls (NIP-69)
- [x] Moderated Communities (NIP-72)
- [ ] Zap Goals (NIP-75)
- [ ] Arbitrary Custom App Data (NIP-78)
- [x] Highlights (NIP-84)
- [x] Recommended Application Handlers (NIP-89)
- [ ] Data Vending Machine (NIP-90)
- [x] Inline Metadata (NIP-92)
- [x] Verifiable file URLs (NIP-94)
- [x] Binary Blobs (NIP-95)
- [x] HTTP File Storage Integration (NIP-96 Draft)
- [x] HTTP Auth (NIP-98)
- [x] Classifieds (NIP-99)
- [x] Private Messages and Small Groups (NIP-24/Draft)
- [x] Versioned Encrypted Payloads (NIP-44/Draft)
- [x] Audio Tracks (zapstr.live) (Kind:31337)
- [x] Push Notifications (Google and Unified Push)
- [x] In-Device Automatic Translations
- [x] Hashtag Following and Custom Hashtags
- [x] Login with QR
- [x] Bounty support (nostrbounties.com)
- [x] De-googled F-Droid flavor
- [x] External Identity Support (NIP-39)
- [x] Multiple Accounts
- [x] Markdown Support
- [x] Relay Authentication (NIP-42)
- [x] Content stored in relays themselves (NIP-95)
- [x] Custom Emoji (NIP-30)
- [x] Zap Forwarding (NIP-57)
- [x] Text Note References (NIP-27)
- [x] Audio Tracks (zapstr.live) (Kind:31337)
- [x] Push Notifications (Zaps and Messages)
- [x] Generic Tags (NIP-12)
- [x] Sensitive Content (NIP-36)
- [x] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [x] Recommended Application Handlers (NIP-89)
- [x] Events with a Subject (NIP-14)
- [x] Generic Reposts (kind:16)
- [x] Live Activities & Live Chats (NIP-53)
- [x] Relay Pages (NIP-11)
- [x] HTTP Auth (NIP-98)
- [x] Zapraiser (NIP-TBD)
- [x] Moderated Communities (NIP-72)
- [x] Emoji Packs (Kind:30030)
- [x] Personal Emoji Lists (Kind:10030)
- [x] Classifieds (Kind:30403)
- [x] Private Messages and Small Groups (NIP-24)
- [x] Gift Wraps & Seals (NIP-59)
- [x] Versioned Encrypted Payloads (NIP-44)
- [x] Expiration Support (NIP-40)
- [x] Status Event (NIP-38)
- [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
- [ ] Workspaces
- [ ] Infinity Scroll
- [ ] Relay List Metadata (NIP-65)
- [ ] Signing Requests (NIP-46)
- [ ] Delegated Event Signing (NIP-26)
- [ ] Account Creation / Backup Guidance
- [ ] Mnemonic seed phrase (NIP-06)
- [ ] Message Sent feedback (NIP-20)
- [ ] OpenTimestamps Attestations (NIP-03)
# Development Overview
## Overall Architecture
## Overall Architecture
This is a native Android app made with Kotlin and Jetpack Compose.
The app uses a modified version of the [nostrpostrlib](https://github.com/Giszmo/NostrPostr/tree/master/nostrpostrlib) to talk to Nostr relays.
@@ -110,13 +113,13 @@ The overall architecture consists of the UI, which uses the usual State/ViewMode
and the model/repository layer, which keeps all Nostr objects in memory, in a full OO graph.
The repository layer stores Nostr Events as Notes and Users separately. Those classes use LiveData objects to
allow the UI and other parts of the app to subscribe to each individual Note/User and receive updates when they happen.
allow the UI and other parts of the app to subscribe to each Note/User and receive updates when they happen.
They are also responsible for updating viewModels when needed. Filters react to changes in the screen. As the user
sees different Events, the Datasource classes are used to receive more information about those particular Events.
Most of the UI is reactive to changes in the repository classes. The service layer assembles Nostr filters for each need of the app,
receives the data from the Relay, and sends it to the repository. Connection with relays is never closed during the use of the app.
The UI receives a notification that objects were updated. Instances of User and Notes are mutable directly.
The UI receives a notification that objects have been updated. Instances of User and Notes are mutable directly.
There will never be two Notes with the same ID or two User instances with the same pubkey.
Lastly, the user's account information (priv key/pub key) is stored in the Android KeyStore for security.
@@ -128,7 +131,7 @@ Make sure to have the following pre-requisites installed:
2. Android Studio
3. Android 8.0+ Phone or Emulation setup
Fork and clone this repository and import into Android Studio
Fork and clone this repository and import it into Android Studio
```bash
git clone https://github.com/vitorpamplona/amethyst.git
```
@@ -149,8 +152,8 @@ Build the app:
## Linting
```bash
./gradlew ktlintCheck
./gradlew ktlintFormat
./gradlew spotlessCheck
./gradlew spotlessApply
```
## Installing on device
@@ -165,6 +168,14 @@ For the Play build:
./gradlew installPlayDebug
```
## Contributing
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) here are also very welcome.
Translations can be provided via [Crowdin](https://crowdin.com/project/amethyst-social)
You can also send patches through Nostr using [GitStr](https://github.com/fiatjaf/gitstr) to [this nostr address](https://patch34.pages.dev/naddr1qqyxzmt9w358jum5qyg8v6t5daezumn0wd68yvfwvdhk6qg7waehxw309ahx7um5wgkhqatz9emk2mrvdaexgetj9ehx2ap0qy2hwumn8ghj7un9d3shjtnwdaehgu3wvfnj7q3qgcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqxpqqqpmej720gac)
## How to Deploy
1. Generate a new signing key
@@ -182,10 +193,10 @@ openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.j
5. Tag the commit with `v{x.x.x}`
6. Let the [Create Release GitHub Action](https://github.com/vitorpamplona/amethyst/actions/workflows/create-release.yml) build a new `aab` file.
7. Add your CHANGE LOG to the description of the new release
8. Download the `aab` file and upload it to the` PlayStore.
8. Download the `aab` file and upload it to the PlayStore.
# Privacy on Relays & nostr
Your internet protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
Your internet protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
The relay also learns which public keys you are requesting, meaning your public key will be tied to your IP address.
@@ -196,22 +207,18 @@ While the content of direct messages (DMs) is only visible to you and your DM co
# Visibility & Permanence of Your Content on nostr
## Information Visibility ##
Content that you share can be shared to other relays.
Content that you share can be shared to other relays.
Information that you share publicly is visible to anyone reading from relays that have your information. Your information may also be visible to nostr users who do not share relays with you.
## Information Permanence ##
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee edit or deletion of any content once posted.
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee edit or deletion of any content once posted.
# Screenshots
| Home Feed | Messages | Live Streams | Notifications |
| FollowFeeds | ChatsGroup | LiveStreams | Notifications |
|-------------------------------------------|----------------------------------------------|-------------------------------------------------|--------------------------------------------------------|
| ![Home Feed](./docs/screenshots/home.png) | ![Messages](./docs/screenshots/messages.png) | ![Live Streams](./docs/screenshots/replies.png) | ![Notifications](./docs/screenshots/notifications.png) |
# Contributing
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome.
## Contributors
<a align="center" href="https://github.com/vitorpamplona/amethyst/graphs/contributors">

View File

@@ -1,7 +1,6 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jlleitschuh.gradle.ktlint' version "11.6.1"
id 'com.google.gms.google-services'
}
@@ -13,19 +12,82 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 327
versionName "0.80.4"
versionCode 354
versionName "0.83.13"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'in', 'ja', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'th', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW']
resourceConfigurations += [
'ar',
'bn-rBD',
'cs',
'cy-rGB',
'da-rDK',
'de',
'el-rGR',
'en-rGB',
'eo',
'es',
'es-rES',
'es-rMX',
'es-rUS',
'et-rEE',
'fa',
'fi-rFI',
'fo-rFO',
'fr',
'fr-rCA',
'gu-rIN',
'hi-rIN',
'hr-rHR',
'hu',
'in',
'in-rID',
'it-rIT',
'iw-rIL',
'ja',
'kk-rKZ',
'ko-rKR',
'ks-rIN',
'ku-rTR',
'lt-rLT',
'ne-rNP',
'night',
'nl',
'nl-rBE',
'pcm-rNG',
'pl-rPL',
'pt-rBR',
'pt-rPT',
'ru',
'ru-rUA',
'sa-rIN',
'sl-rSI',
'so-rSO',
'sr-rSP',
'ss-rZA',
'sv-rSE',
'sw-rKE',
'sw-rTZ',
'ta',
'th',
'tr',
'uk',
'ur-rIN',
'uz-rUZ',
'vi-rVN',
'zh',
'zh-rCN',
'zh-rHK',
'zh-rSG',
'zh-rTW'
]
}
buildTypes {
release {
//signingConfig signingConfigs.debug
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), 'proguard-rules.pro'
minifyEnabled true
resValue "string", "app_name", "@string/app_name_release"
@@ -35,6 +97,11 @@ android {
versionNameSuffix '-DEBUG'
resValue "string", "app_name", "@string/app_name_debug"
}
create("benchmark") {
initWith(getByName("release"))
profileable true
signingConfig signingConfigs.debug
}
}
flavorDimensions = ["channel"]
@@ -70,10 +137,12 @@ android {
buildFeatures {
compose true
buildConfig true
}
composeOptions {
kotlinCompilerExtensionVersion "1.5.1"
// Should match compose version : https://developer.android.com/jetpack/androidx/releases/compose-kotlin
kotlinCompilerExtensionVersion "1.5.8"
}
packagingOptions {
@@ -93,8 +162,9 @@ android {
dependencies {
implementation project(path: ':quartz')
implementation project(path: ':commons')
implementation "androidx.core:core-ktx:$core_ktx_version"
implementation 'androidx.activity:activity-compose:1.8.0'
implementation 'androidx.activity:activity-compose:1.8.2'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
@@ -113,7 +183,7 @@ dependencies {
// Adaptive Layout / Two Pane
implementation "androidx.compose.material3:material3-window-size-class:${material3_version}"
implementation "com.google.accompanist:accompanist-adaptive:0.32.0"
implementation 'com.google.accompanist:accompanist-adaptive:0.34.0'
// Lifecycle
@@ -123,24 +193,28 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// Zoomable images
implementation 'net.engawapg.lib:zoomable:1.5.1'
implementation 'net.engawapg.lib:zoomable:1.6.0'
// Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
// Websockets API
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.12'
// HTML Parsing for Link Preview
implementation 'org.jsoup:jsoup:1.16.2'
implementation 'org.jsoup:jsoup:1.17.2'
// Encrypted Key Storage
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
// view videos
implementation "androidx.media3:media3-exoplayer:${exoplayer_version}"
implementation "androidx.media3:media3-exoplayer:$media3_version"
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
implementation "androidx.media3:media3-ui:$media3_version"
implementation "androidx.media3:media3-session:$media3_version"
// important for proxy / tor
implementation "androidx.media3:media3-datasource-okhttp:${exoplayer_version}"
implementation "androidx.media3:media3-datasource-okhttp:$media3_version"
// Load images from the web.
implementation "io.coil-kt:coil-compose:$coil_version"
@@ -156,7 +230,7 @@ dependencies {
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
// For QR generation
implementation 'com.google.zxing:core:3.5.2'
implementation 'com.google.zxing:core:3.5.3'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
// Markdown
@@ -165,24 +239,21 @@ dependencies {
//implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
// Markdown (With fix for full-image bleeds)
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:a0954aba63')
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material3:a0954aba63')
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:a0954aba63')
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:48702a8ced')
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material3:48702a8ced')
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:48702a8ced')
// Language picker and Theme chooser
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
implementation "androidx.media3:media3-ui:$media3_version"
implementation "androidx.media3:media3-session:$media3_version"
// Local model for language identification
playImplementation 'com.google.mlkit:language-id:17.0.4'
// Google services model the translate text
playImplementation 'com.google.mlkit:translate:17.0.1'
playImplementation 'com.google.mlkit:translate:17.0.2'
// PushNotifications
playImplementation platform('com.google.firebase:firebase-bom:32.4.1')
playImplementation platform('com.google.firebase:firebase-bom:32.7.1')
playImplementation 'com.google.firebase:firebase-messaging-ktx'
//PushNotifications(FDroid)
@@ -201,13 +272,14 @@ dependencies {
implementation 'com.github.lincollincol:compose-audiowaveform:1.1.1'
// Video compression lib
implementation 'com.github.AbedElazizShe:LightCompressor:1.3.1'
implementation 'com.github.AbedElazizShe:LightCompressor:1.3.2'
// Image compression lib
implementation 'id.zelory:compressor:3.0.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.8'
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha01'
testImplementation 'io.mockk:mockk:1.13.9'
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha03'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.0-alpha03'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"

View File

@@ -1,78 +1,194 @@
/**
* Copyright (c) 2023 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.model.Account
import com.vitorpamplona.amethyst.ui.actions.FileServer
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
import com.vitorpamplona.amethyst.ui.actions.ImgurServer
import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer
import com.vitorpamplona.amethyst.ui.actions.NostrFilesDevServer
import com.vitorpamplona.amethyst.ui.actions.NostrImgServer
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.quartz.crypto.KeyPair
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue
import junit.framework.TestCase.fail
import kotlinx.coroutines.runBlocking
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
import java.util.Base64
import java.util.concurrent.CountDownLatch
@RunWith(AndroidJUnit4::class)
class ImageUploadTesting {
val contentType = "image/gif"
val image =
"R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzW" +
"lwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2c" +
"cMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjA" +
"J8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8A" +
"AF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMu" +
"QeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSH" +
"pzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGR" +
"s/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78A" +
"AAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMi" +
"wocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7G" +
"nwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euT" +
"eJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dt" +
"GCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWl" +
"Mc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPe" +
"iUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYI" +
"m4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZ" +
"cNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9" +
"aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3A" +
"DTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kV" +
"MyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDG" +
"qCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMW" +
"ZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bD" +
"GdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB77" +
"6aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJH" +
"gxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiA" +
"FB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPA" +
"gCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHg" +
"rhGSQJxCS+0pCZbEhAAOw=="
val image = "R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw=="
val contentTypePng = "image/png"
val imagePng =
"iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3" +
"/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXd" +
"tdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEn" +
"xBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nH" +
"L0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2ud" +
"LFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8" +
"Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoeP" +
"PQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/" +
"9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlw" +
"jlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN97" +
"9jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC1" +
"7MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2r" +
"eNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+h" +
"uNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66Pfyu" +
"Rj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMT" +
"hZ3kvgLI5AzFfo379UAAAAASUVORK5CYII="
private fun testBase(server: FileServer) {
val bytes = Base64.getDecoder().decode(image)
private suspend fun testBase(server: Nip96MediaServers.ServerName) {
val serverInfo =
Nip96Retriever()
.loadInfo(
server.baseUrl,
)
val bytes = Base64.getDecoder().decode(imagePng)
val inputStream = bytes.inputStream()
val countDownLatch = CountDownLatch(1)
var url: String? = null
var error: String? = null
val account = Account(KeyPair())
ImageUploader.account = Account(
KeyPair()
)
val result =
Nip96Uploader(account)
.uploadImage(
inputStream,
bytes.size.toLong(),
contentTypePng,
alt = null,
sensitiveContent = null,
serverInfo,
onProgress = {},
)
ImageUploader.uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
server,
onSuccess = { newUrl, contentType ->
println("Uploaded $contentType to $url")
url = newUrl
countDownLatch.countDown()
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(url.startsWith("http"))
val imageData: ByteArray =
ImageDownloader().waitAndGetImage(url)
?: run {
fail("Should not be null")
return
}
FileHeader.prepare(
imageData,
contentTypePng,
null,
onReady = {
if (dim != null) {
assertEquals(dim, it.dim)
}
if (size != null) {
assertEquals(size, it.size.toString())
}
if (hash != null) {
assertEquals(hash, it.hash)
}
},
onError = {
println("Failed to Upload")
error = it.message
countDownLatch.countDown()
}
onError = { fail("It should not fail") },
)
countDownLatch.await()
// delay(1000)
Assert.assertNull(error)
Assert.assertTrue(url?.startsWith("http") == true)
// assertTrue(Nip96Uploader(account).delete(ox, contentType, serverInfo))
}
@Test()
fun testImgurUpload() = runBlocking {
testBase(ImgurServer())
}
fun testNostrCheck() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me"))
}
@Test()
fun testNostrBuildUpload() = runBlocking {
testBase(NostrBuildServer())
}
fun testNostrage() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com"))
}
@Test()
fun testNostrImgUpload() = runBlocking {
testBase(NostrImgServer())
}
fun testSove() =
runBlocking {
testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent"))
}
@Test()
fun testNostrFilesDevUpload() = runBlocking {
testBase(NostrFilesDevServer())
}
fun testNostrBuild() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build"))
}
@Test()
fun testSovbit() =
runBlocking {
testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host"))
}
@Test()
fun testVoidCat() =
runBlocking {
testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat"))
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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.compose.ui.graphics.Color
@@ -29,17 +49,19 @@ class UrlUserTagTransformationTest {
@Test
fun transformationText() {
val user = LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey()
)
val user =
LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
val transformedText = buildAnnotatedStringWithUrlHighlighting(
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
Color.Red
)
val transformedText =
buildAnnotatedStringWithUrlHighlighting(
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
Color.Red,
)
assertEquals("New Hey @Vitor Pamplona", transformedText.text.text)
@@ -66,17 +88,21 @@ class UrlUserTagTransformationTest {
@Test
fun transformationTextTwoKeys() {
val user = LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey()
)
val user =
LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
val transformedText = buildAnnotatedStringWithUrlHighlighting(
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
Color.Red
)
val transformedText =
buildAnnotatedStringWithUrlHighlighting(
AnnotatedString(
"New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
),
Color.Red,
)
assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text)

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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
@@ -10,17 +30,27 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class TranslationsTest {
fun translateTo(text: String, translateTo: String): String? {
fun translateTo(
text: String,
translateTo: String,
): String? {
val task = LanguageTranslatorService.autoTranslate(text, emptySet(), translateTo)
return Tasks.await(task).result
}
fun assertTranslate(expected: String, input: String, translateTo: String) {
fun assertTranslate(
expected: String,
input: String,
translateTo: String,
) {
assertEquals(null, expected, translateTo(input, translateTo))
}
fun assertTranslateContains(expected: String, input: String, translateTo: String) {
fun assertTranslateContains(
expected: String,
input: String,
translateTo: String,
) {
val translated = translateTo(input, translateTo)!!
assertTrue("'$translated' does not contain '$expected'", translated.contains(expected))
}
@@ -44,23 +74,47 @@ class TranslationsTest {
fun testTranslationUrl() {
assertTranslateContains("https://t.me/mygroup", "Have you seen this https://t.me/mygroup", "pt")
assertTranslateContains("http://bananas.com", "Have you seen this http://bananas.com", "pt")
assertTranslateContains("http://bananas.com/myimage.jpg", "Have you seen this http://bananas.com/myimage.jpg", "pt")
assertTranslateContains("http://bananas.com?search=true&image=myimage.jpg", "Have you seen this http://bananas.com?search=true&image=myimage.jpg", "pt")
assertTranslateContains(
"http://bananas.com/myimage.jpg",
"Have you seen this http://bananas.com/myimage.jpg",
"pt",
)
assertTranslateContains(
"http://bananas.com?search=true&image=myimage.jpg",
"Have you seen this http://bananas.com?search=true&image=myimage.jpg",
"pt",
)
assertTranslate("https://i.imgur.com/EZ3QPsw.jpg", "https://i.imgur.com/EZ3QPsw.jpg", "pt")
assertTranslate("https://HaveYouSeenThis.com", "https://HaveYouSeenThis.com", "pt")
assertTranslate("https://haveyouseenthis.com", "https://haveyouseenthis.com", "pt")
assertTranslate("https://i.imgur.com/asdEZ3QPsw.jpg", "https://i.imgur.com/asdEZ3QPsw.jpg", "pt")
assertTranslateContains("https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "Hi there! \n How are you doing? \n https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "pt")
assertTranslate(
"https://i.imgur.com/asdEZ3QPsw.jpg",
"https://i.imgur.com/asdEZ3QPsw.jpg",
"pt",
)
assertTranslateContains(
"https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg",
"Hi there! \n How are you doing? \n https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg",
"pt",
)
}
@Test
fun testChineseWithUrlDetector() {
assertTranslate("I entered your home page is very carton, perhaps your attention or other data is too much, and the homepage of others is not so carton. From aMethyst client", "我进入你的主页很卡顿也许是你的关注人数或者其他数据太多了其他人主页没有这么卡顿。来自amethyst客户端", "en")
assertTranslate(
"I entered your home page is very carton, perhaps your attention or other data is too much, and the homepage of others is not so carton. From aMethyst client",
"我进入你的主页很卡顿也许是你的关注人数或者其他数据太多了其他人主页没有这么卡顿。来自amethyst客户端",
"en",
)
}
@Test
fun testTranslationEmail() {
assertTranslateContains("vitor@amethyst.social", "Have you seen this vitor@amethyst.social", "pt")
assertTranslateContains(
"vitor@amethyst.social",
"Have you seen this vitor@amethyst.social",
"pt",
)
}
@Test
@@ -68,13 +122,13 @@ class TranslationsTest {
assertTranslateContains(
"lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn",
"Have you seen this: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn I think I have to pay",
"pt"
"pt",
)
assertTranslateContains(
"lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
"Test lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
"pt"
"pt",
)
}
@@ -83,7 +137,7 @@ class TranslationsTest {
assertTranslateContains(
"nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
"sure, nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
"en"
"en",
)
}
@@ -96,7 +150,7 @@ class TranslationsTest {
"https://youtu.be/wMYFmCDy_Eg\n" +
"\n" +
"先輩がうざい後輩の話と似たような話かと思ったけど、もっとオタクの妄想あるある的なものを詰め込んだやつだ。ワードとかシチュエーションとか、ヒロインのサイズ感とか。知らんけど",
"en"
"en",
)
}
@@ -105,7 +159,7 @@ class TranslationsTest {
assertTranslateContains(
"https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg",
"\uD83E\uDD23 https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg ",
"pt"
"pt",
)
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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.lang
object LanguageTranslatorService {

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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.Context
@@ -9,10 +29,14 @@ import org.unifiedpush.android.connector.UnifiedPush
interface PushDistributorActions {
fun getSavedDistributor(): String
fun getInstalledDistributors(): List<String>
fun saveDistributor(distributor: String)
fun removeSavedDistributor()
}
object PushDistributorHandler : PushDistributorActions {
private val appContext = Amethyst.instance.applicationContext
private val unifiedPush: UnifiedPush = UnifiedPush
@@ -21,6 +45,7 @@ object PushDistributorHandler : PushDistributorActions {
val endpoint = endpointInternal
fun getSavedEndpoint() = endpoint
fun setEndpoint(newEndpoint: String) {
endpointInternal = newEndpoint
Log.d("PushHandler", "New endpoint saved : $endpointInternal")
@@ -42,23 +67,28 @@ object PushDistributorHandler : PushDistributorActions {
fun formattedDistributorNames(): List<String> {
val distributorsArray = getInstalledDistributors().toTypedArray()
val distributorsNameArray = distributorsArray.map {
try {
val ai = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
appContext.packageManager.getApplicationInfo(
it,
PackageManager.ApplicationInfoFlags.of(
PackageManager.GET_META_DATA.toLong()
)
)
} else {
appContext.packageManager.getApplicationInfo(it, 0)
val distributorsNameArray =
distributorsArray
.map {
try {
val ai =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
appContext.packageManager.getApplicationInfo(
it,
PackageManager.ApplicationInfoFlags.of(
PackageManager.GET_META_DATA.toLong(),
),
)
} else {
appContext.packageManager.getApplicationInfo(it, 0)
}
appContext.packageManager.getApplicationLabel(ai)
} catch (e: PackageManager.NameNotFoundException) {
it
}
as String
}
appContext.packageManager.getApplicationLabel(ai)
} catch (e: PackageManager.NameNotFoundException) {
it
} as String
}.toTypedArray()
.toTypedArray()
return distributorsNameArray.toList()
}
@@ -70,6 +100,7 @@ object PushDistributorHandler : PushDistributorActions {
override fun removeSavedDistributor() {
unifiedPush.safeRemoveDistributor(appContext)
}
fun forceRemoveDistributor(context: Context) {
unifiedPush.forceRemoveDistributor(context)
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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.app.NotificationManager
@@ -12,6 +32,7 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrC
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel
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
@@ -20,21 +41,27 @@ import kotlinx.coroutines.launch
import org.unifiedpush.android.connector.MessagingReceiver
class PushMessageReceiver : MessagingReceiver() {
private val TAG = "Amethyst-OSSPushReceiver"
companion object {
private val TAG = "Amethyst-OSSPushReceiver"
}
private val appContext = Amethyst.instance.applicationContext
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val eventCache = LruCache<String, String>(100)
private val pushHandler = PushDistributorHandler
override fun onMessage(context: Context, message: ByteArray, instance: String) {
override fun onMessage(
context: Context,
message: ByteArray,
instance: String,
) {
val messageStr = String(message)
Log.d(TAG, "New message ${message.decodeToString()} 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}")
}
}
@@ -54,7 +81,11 @@ class PushMessageReceiver : MessagingReceiver() {
}
}
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
override fun onNewEndpoint(
context: Context,
endpoint: String,
instance: String,
) {
Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance")
val sanitizedEndpoint = endpoint.dropLast(5)
pushHandler.setEndpoint(sanitizedEndpoint)
@@ -65,20 +96,29 @@ class PushMessageReceiver : MessagingReceiver() {
}
}
override fun onReceive(context: Context, intent: Intent) {
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) {
override fun onRegistrationFailed(
context: Context,
instance: String,
) {
Log.d(TAG, "Registration failed for Instance: $instance")
scope.cancel()
pushHandler.forceRemoveDistributor(context)
}
override fun onUnregistered(context: Context, instance: String) {
override fun onUnregistered(
context: Context,
instance: String,
) {
val removedEndpoint = pushHandler.endpoint
Log.d(TAG, "Endpoint: $removedEndpoint removed for Instance: $instance")
Log.d(TAG, "App is unregistered. ")
@@ -87,6 +127,7 @@ class PushMessageReceiver : MessagingReceiver() {
}
fun notificationManager(): NotificationManager {
return ContextCompat.getSystemService(appContext, NotificationManager::class.java) as NotificationManager
return ContextCompat.getSystemService(appContext, NotificationManager::class.java)
as NotificationManager
}
}

View File

@@ -1,11 +1,33 @@
/**
* Copyright (c) 2023 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.util.Log
import com.vitorpamplona.amethyst.AccountInfo
import kotlinx.coroutines.CancellationException
object PushNotificationUtils {
var hasInit: Boolean = false
private val pushHandler = PushDistributorHandler
suspend fun init(accounts: List<AccountInfo>) {
if (hasInit || pushHandler.savedDistributorExists()) {
return
@@ -15,6 +37,7 @@ object PushNotificationUtils {
RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint())
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.d("Amethyst-OSSPushUtils", "Failed to get endpoint.")
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import android.util.Log
@@ -44,7 +64,8 @@ import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) {
val notificationPermissionState = CheckifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel)
val notificationPermissionState =
CheckifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel)
if (notificationPermissionState.status.isGranted) {
if (!sharedPreferencesViewModel.sharedPrefs.dontShowPushNotificationSelector) {
@@ -53,7 +74,7 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
mutableStateOf(PushDistributorHandler.savedDistributorExists())
}
if (!distributorPresent) {
LoadDistributors() { currentDistributor, list, readableListWithExplainer ->
LoadDistributors { currentDistributor, list, readableListWithExplainer ->
if (readableListWithExplainer.size > 1) {
SpinnerSelectionDialog(
title = stringResource(id = R.string.select_push_server),
@@ -73,54 +94,51 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
onDismiss = {
distributorPresent = true
Log.d("Amethyst", "NotificationScreen: Distributor dialog dismissed.")
}
},
)
} else {
AlertDialog(
onDismissRequest = {
distributorPresent = true
},
title = {
Text(stringResource(R.string.push_server_install_app))
},
onDismissRequest = { distributorPresent = true },
title = { Text(stringResource(R.string.push_server_install_app)) },
text = {
Material3RichText(
style = RichTextStyle().resolveDefaults()
style = RichTextStyle().resolveDefaults(),
) {
Markdown(
content = stringResource(R.string.push_server_install_app_description)
content = stringResource(R.string.push_server_install_app_description),
)
}
},
confirmButton = {
Row(
modifier = Modifier
.padding(all = 8.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
modifier = Modifier.padding(all = 8.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextButton(onClick = {
distributorPresent = true
sharedPreferencesViewModel.dontShowPushNotificationSelector()
}) {
TextButton(
onClick = {
distributorPresent = true
sharedPreferencesViewModel.dontShowPushNotificationSelector()
},
) {
Text(stringResource(R.string.quick_action_dont_show_again_button))
}
Button(onClick = {
distributorPresent = true
}, contentPadding = PaddingValues(horizontal = 16.dp)) {
Button(
onClick = { distributorPresent = true },
contentPadding = PaddingValues(horizontal = 16.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null
contentDescription = null,
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.error_dialog_button_ok))
}
}
}
}
},
)
}
}
@@ -133,35 +151,34 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
}
@Composable
fun LoadDistributors(
onInner: @Composable (String, ImmutableList<String>, ImmutableList<TitleExplainer>) -> Unit
) {
fun LoadDistributors(onInner: @Composable (String, ImmutableList<String>, ImmutableList<TitleExplainer>) -> Unit) {
val currentDistributor = PushDistributorHandler.getSavedDistributor().ifBlank { null } ?: "None"
val list = remember {
PushDistributorHandler.getInstalledDistributors().plus("None").toImmutableList()
}
val list =
remember {
PushDistributorHandler.getInstalledDistributors().plus("None").toImmutableList()
}
val readableListWithExplainer =
PushDistributorHandler.formattedDistributorNames()
.mapIndexed { index, name ->
TitleExplainer(
name,
stringResource(id = R.string.push_server_uses_app_explainer, list[index])
stringResource(id = R.string.push_server_uses_app_explainer, list[index]),
)
}
.plus(
TitleExplainer(
stringResource(id = R.string.push_server_none),
stringResource(id = R.string.push_server_none_explainer)
)
stringResource(id = R.string.push_server_none_explainer),
),
)
.toImmutableList()
onInner(
currentDistributor,
list,
readableListWithExplainer
readableListWithExplainer,
)
}
@@ -169,12 +186,12 @@ fun LoadDistributors(
fun PushNotificationSettingsRow(sharedPreferencesViewModel: SharedPreferencesViewModel) {
val context = LocalContext.current
LoadDistributors() { currentDistributor, list, readableListWithExplainer ->
LoadDistributors { currentDistributor, list, readableListWithExplainer ->
SettingsRow(
R.string.push_server_title,
R.string.push_server_explainer,
selectedItens = readableListWithExplainer,
selectedIndex = list.indexOf(currentDistributor)
selectedIndex = list.indexOf(currentDistributor),
) { index ->
if (list[index] == "None") {
sharedPreferencesViewModel.dontAskForNotificationPermissions()

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
@@ -15,7 +35,7 @@ fun TranslatableRichTextViewer(
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
nav: (String) -> Unit,
) = ExpandableRichTextViewer(
content,
canPreview,
@@ -23,5 +43,5 @@ fun TranslatableRichTextViewer(
tags,
backgroundColor,
accountViewModel,
nav
nav,
)

View File

@@ -3,8 +3,11 @@
xmlns:tools="http://schemas.android.com/tools">
<queries>
<package android:name="org.torproject.android"/>
<package android:name="com.greenart7c3.nostrsigner"/>
<package android:name="com.greenart7c3.nostrsigner.debug"/>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostrsigner" />
</intent>
</queries>
<!-- Doesn't require a camera -->

View File

@@ -1,19 +1,23 @@
/*
* Copyright 2022 The Android Open Source Project
/**
* Copyright (c) 2023 Vitor Pamplona
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* 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:
*
* http://www.apache.org/licenses/LICENSE-2.0
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* 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.pullrefresh
import androidx.compose.ui.Modifier
@@ -32,22 +36,21 @@ import androidx.compose.ui.unit.Velocity
* Note that this modifier must be added above a scrolling container, such as a lazy column, in
* order to receive scroll events. For example:
*
* @sample androidx.compose.material.samples.PullRefreshSample
*
* @param state The [PullRefreshState] associated with this pull-to-refresh component.
* The state will be updated by this modifier.
* @param state The [PullRefreshState] associated with this pull-to-refresh component. The state
* will be updated by this modifier.
* @param enabled If not enabled, all scroll delta and fling velocity will be ignored.
* @sample androidx.compose.material.samples.PullRefreshSample
*/
// TODO(b/244423199): Move pullRefresh into its own material library similar to material-ripple.
fun Modifier.pullRefresh(
state: PullRefreshState,
enabled: Boolean = true
enabled: Boolean = true,
) = inspectable(
inspectorInfo = debugInspectorInfo {
name = "pullRefresh"
properties["state"] = state
properties["enabled"] = enabled
}
inspectorInfo =
debugInspectorInfo {
name = "pullRefresh"
properties["state"] = state
properties["enabled"] = enabled
},
) {
Modifier.pullRefresh(state::onPull, state::onRelease, enabled)
}
@@ -59,32 +62,32 @@ fun Modifier.pullRefresh(
* Note that this modifier must be added above a scrolling container, such as a lazy column, in
* order to receive scroll events. For example:
*
* @sample androidx.compose.material.samples.CustomPullRefreshSample
*
* @param onPull Callback for dispatching vertical scroll delta, takes float pullDelta as argument.
* Positive delta (pulling down) is dispatched only if the child does not consume it (i.e. pulling
* down despite being at the top of a scrollable component), whereas negative delta (swiping up) is
* dispatched first (in case it is needed to push the indicator back up), and then the unconsumed
* delta is passed on to the child. The callback returns how much delta was consumed.
* @param onRelease Callback for when drag is released, takes float flingVelocity as argument.
* The callback returns how much velocity was consumed - in most cases this should only consume
* velocity if pull refresh has been dragged already and the velocity is positive (the fling is
* downwards), as an upwards fling should typically still scroll a scrollable component beneath the
* pullRefresh. This is invoked before any remaining velocity is passed to the child.
* Positive delta (pulling down) is dispatched only if the child does not consume it (i.e. pulling
* down despite being at the top of a scrollable component), whereas negative delta (swiping up)
* is dispatched first (in case it is needed to push the indicator back up), and then the
* unconsumed delta is passed on to the child. The callback returns how much delta was consumed.
* @param onRelease Callback for when drag is released, takes float flingVelocity as argument. The
* callback returns how much velocity was consumed - in most cases this should only consume
* velocity if pull refresh has been dragged already and the velocity is positive (the fling is
* downwards), as an upwards fling should typically still scroll a scrollable component beneath
* the pullRefresh. This is invoked before any remaining velocity is passed to the child.
* @param enabled If not enabled, all scroll delta and fling velocity will be ignored and neither
* [onPull] nor [onRelease] will be invoked.
* [onPull] nor [onRelease] will be invoked.
* @sample androidx.compose.material.samples.CustomPullRefreshSample
*/
fun Modifier.pullRefresh(
onPull: (pullDelta: Float) -> Float,
onRelease: suspend (flingVelocity: Float) -> Float,
enabled: Boolean = true
enabled: Boolean = true,
) = inspectable(
inspectorInfo = debugInspectorInfo {
name = "pullRefresh"
properties["onPull"] = onPull
properties["onRelease"] = onRelease
properties["enabled"] = enabled
}
inspectorInfo =
debugInspectorInfo {
name = "pullRefresh"
properties["onPull"] = onPull
properties["onRelease"] = onRelease
properties["enabled"] = enabled
},
) {
Modifier.nestedScroll(PullRefreshNestedScrollConnection(onPull, onRelease, enabled))
}
@@ -92,27 +95,28 @@ fun Modifier.pullRefresh(
private class PullRefreshNestedScrollConnection(
private val onPull: (pullDelta: Float) -> Float,
private val onRelease: suspend (flingVelocity: Float) -> Float,
private val enabled: Boolean
private val enabled: Boolean,
) : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset = when {
!enabled -> Offset.Zero
source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up
else -> Offset.Zero
}
source: NestedScrollSource,
): Offset =
when {
!enabled -> Offset.Zero
source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up
else -> Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset = when {
!enabled -> Offset.Zero
source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down
else -> Offset.Zero
}
source: NestedScrollSource,
): Offset =
when {
!enabled -> Offset.Zero
source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down
else -> Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
return Velocity(0f, onRelease(available.y))

View File

@@ -1,19 +1,23 @@
/*
* Copyright 2022 The Android Open Source Project
/**
* Copyright (c) 2023 Vitor Pamplona
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* 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:
*
* http://www.apache.org/licenses/LICENSE-2.0
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* 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.pullrefresh
import androidx.compose.animation.Crossfade
@@ -56,17 +60,14 @@ import kotlin.math.pow
/**
* The default indicator for Compose pull-to-refresh, based on Android's SwipeRefreshLayout.
*
* @sample androidx.compose.material.samples.PullRefreshSample
*
* @param refreshing A boolean representing whether a refresh is occurring.
* @param state The [PullRefreshState] which controls where and how the indicator will be drawn.
* @param modifier Modifiers for the indicator.
* @param backgroundColor The color of the indicator's background.
* @param contentColor The color of the indicator's arc and arrow.
* @param scale A boolean controlling whether the indicator's size scales with pull progress or not.
* @sample androidx.compose.material.samples.PullRefreshSample
*/
// TODO(b/244423199): Consider whether the state parameter should be replaced with lambdas to
// enable people to use this indicator with custom pull-to-refresh components.
@Composable
fun PullRefreshIndicator(
refreshing: Boolean,
@@ -74,27 +75,24 @@ fun PullRefreshIndicator(
modifier: Modifier = Modifier,
backgroundColor: Color = MaterialTheme.colorScheme.surface,
contentColor: Color = contentColorFor(backgroundColor),
scale: Boolean = false
scale: Boolean = false,
) {
val showElevation by remember(refreshing, state) {
derivedStateOf { refreshing || state.position > 0.5f }
}
val showElevation by
remember(refreshing, state) { derivedStateOf { refreshing || state.position > 0.5f } }
Surface(
modifier = modifier
.size(IndicatorSize)
.pullRefreshIndicatorTransform(state, scale),
modifier = modifier.size(IndicatorSize).pullRefreshIndicatorTransform(state, scale),
shape = SpinnerShape,
color = backgroundColor,
shadowElevation = if (showElevation) Elevation else 0.dp
shadowElevation = if (showElevation) Elevation else 0.dp,
) {
Crossfade(
targetState = refreshing,
animationSpec = tween(durationMillis = CrossfadeDurationMs)
animationSpec = tween(durationMillis = CROSSFADE_DURATION_MS),
) { refreshing ->
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
contentAlignment = Alignment.Center,
) {
val spinnerSize = (ArcRadius + StrokeWidth).times(2)
@@ -102,7 +100,7 @@ fun PullRefreshIndicator(
CircularProgressIndicator(
color = contentColor,
strokeWidth = StrokeWidth,
modifier = Modifier.size(spinnerSize)
modifier = Modifier.size(spinnerSize),
)
} else {
CircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize))
@@ -112,22 +110,17 @@ fun PullRefreshIndicator(
}
}
/**
* Modifier.size MUST be specified.
*/
/** Modifier.size MUST be specified. */
@Composable
private fun CircularArrowIndicator(
state: PullRefreshState,
color: Color,
modifier: Modifier
modifier: Modifier,
) {
val path = remember { Path().apply { fillType = PathFillType.EvenOdd } }
val targetAlpha by remember(state) {
derivedStateOf {
if (state.progress >= 1f) MaxAlpha else MinAlpha
}
}
val targetAlpha by
remember(state) { derivedStateOf { if (state.progress >= 1f) MAX_ALPHA else MIN_ALPHA } }
val alphaState = animateFloatAsState(targetValue = targetAlpha, animationSpec = AlphaTween)
@@ -138,12 +131,13 @@ private fun CircularArrowIndicator(
rotate(degrees = values.rotation) {
val arcRadius = ArcRadius.toPx() + StrokeWidth.toPx() / 2f
val arcBounds = Rect(
size.center.x - arcRadius,
size.center.y - arcRadius,
size.center.x + arcRadius,
size.center.y + arcRadius
)
val arcBounds =
Rect(
size.center.x - arcRadius,
size.center.y - arcRadius,
size.center.x + arcRadius,
size.center.y + arcRadius,
)
drawArc(
color = color,
alpha = alpha,
@@ -152,10 +146,11 @@ private fun CircularArrowIndicator(
useCenter = false,
topLeft = arcBounds.topLeft,
size = arcBounds.size,
style = Stroke(
width = StrokeWidth.toPx(),
cap = StrokeCap.Square
)
style =
Stroke(
width = StrokeWidth.toPx(),
cap = StrokeCap.Square,
),
)
drawArrow(path, arcBounds, color, alpha, values)
}
@@ -167,7 +162,7 @@ private class ArrowValues(
val rotation: Float,
val startAngle: Float,
val endAngle: Float,
val scale: Float
val scale: Float,
)
private fun ArrowValues(progress: Float): ArrowValues {
@@ -181,7 +176,7 @@ private fun ArrowValues(progress: Float): ArrowValues {
val tensionPercent = linearTension - linearTension.pow(2) / 4
// Calculations based on SwipeRefreshLayout specification.
val endTrim = adjustedPercent * MaxProgressArc
val endTrim = adjustedPercent * MAX_PROGRESS_ARC
val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent) * 0.5f
val startAngle = rotation * 360
val endAngle = (rotation + endTrim) * 360
@@ -195,7 +190,7 @@ private fun DrawScope.drawArrow(
bounds: Rect,
color: Color,
alpha: Float,
values: ArrowValues
values: ArrowValues,
) {
arrow.reset()
arrow.moveTo(0f, 0f) // Move to left corner
@@ -204,7 +199,7 @@ private fun DrawScope.drawArrow(
// Line to tip of arrow
arrow.lineTo(
x = ArrowWidth.toPx() * values.scale / 2,
y = ArrowHeight.toPx() * values.scale
y = ArrowHeight.toPx() * values.scale,
)
val radius = min(bounds.width, bounds.height) / 2f
@@ -212,17 +207,15 @@ private fun DrawScope.drawArrow(
arrow.translate(
Offset(
x = radius + bounds.center.x - inset,
y = bounds.center.y + StrokeWidth.toPx() / 2f
)
y = bounds.center.y + StrokeWidth.toPx() / 2f,
),
)
arrow.close()
rotate(degrees = values.endAngle) {
drawPath(path = arrow, color = color, alpha = alpha)
}
rotate(degrees = values.endAngle) { drawPath(path = arrow, color = color, alpha = alpha) }
}
private const val CrossfadeDurationMs = 100
private const val MaxProgressArc = 0.8f
private const val CROSSFADE_DURATION_MS = 100
private const val MAX_PROGRESS_ARC = 0.8f
private val IndicatorSize = 40.dp
private val SpinnerShape = CircleShape
@@ -233,6 +226,6 @@ private val ArrowHeight = 5.dp
private val Elevation = 6.dp
// Values taken from SwipeRefreshLayout
private const val MinAlpha = 0.3f
private const val MaxAlpha = 1f
private const val MIN_ALPHA = 0.3f
private const val MAX_ALPHA = 1f
private val AlphaTween = tween<Float>(300, easing = LinearEasing)

View File

@@ -1,19 +1,23 @@
/*
* Copyright 2022 The Android Open Source Project
/**
* Copyright (c) 2023 Vitor Pamplona
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* 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:
*
* http://www.apache.org/licenses/LICENSE-2.0
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* 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.pullrefresh
import androidx.compose.animation.core.LinearOutSlowInEasing
@@ -25,24 +29,23 @@ import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.platform.inspectable
/**
* A modifier for translating the position and scaling the size of a pull-to-refresh indicator
* based on the given [PullRefreshState].
*
* @sample androidx.compose.material.samples.PullRefreshIndicatorTransformSample
* A modifier for translating the position and scaling the size of a pull-to-refresh indicator based
* on the given [PullRefreshState].
*
* @param state The [PullRefreshState] which determines the position of the indicator.
* @param scale A boolean controlling whether the indicator's size scales with pull progress or not.
* @sample androidx.compose.material.samples.PullRefreshIndicatorTransformSample
*/
// TODO: Consider whether the state parameter should be replaced with lambdas.
fun Modifier.pullRefreshIndicatorTransform(
state: PullRefreshState,
scale: Boolean = false
scale: Boolean = false,
) = inspectable(
inspectorInfo = debugInspectorInfo {
name = "pullRefreshIndicatorTransform"
properties["state"] = state
properties["scale"] = scale
}
inspectorInfo =
debugInspectorInfo {
name = "pullRefreshIndicatorTransform"
properties["state"] = state
properties["scale"] = scale
},
) {
Modifier
// Essentially we only want to clip the at the top, so the indicator will not appear when
@@ -56,7 +59,7 @@ fun Modifier.pullRefreshIndicatorTransform(
top = 0f,
left = -Float.MAX_VALUE,
right = Float.MAX_VALUE,
bottom = Float.MAX_VALUE
bottom = Float.MAX_VALUE,
) {
this@drawWithContent.drawContent()
}
@@ -65,9 +68,8 @@ fun Modifier.pullRefreshIndicatorTransform(
translationY = state.position - size.height
if (scale && !state.refreshing) {
val scaleFraction = LinearOutSlowInEasing
.transform(state.position / state.threshold)
.coerceIn(0f, 1f)
val scaleFraction =
LinearOutSlowInEasing.transform(state.position / state.threshold).coerceIn(0f, 1f)
scaleX = scaleFraction
scaleY = scaleFraction
}

View File

@@ -1,19 +1,23 @@
/*
* Copyright 2022 The Android Open Source Project
/**
* Copyright (c) 2023 Vitor Pamplona
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* 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:
*
* http://www.apache.org/licenses/LICENSE-2.0
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* 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.pullrefresh
import androidx.compose.animation.core.animate
@@ -41,21 +45,20 @@ import kotlin.math.pow
*
* Changes to [refreshing] will result in [PullRefreshState] being updated.
*
* @sample androidx.compose.material.samples.PullRefreshSample
*
* @param refreshing A boolean representing whether a refresh is currently occurring.
* @param onRefresh The function to be called to trigger a refresh.
* @param refreshThreshold The threshold below which, if a release
* occurs, [onRefresh] will be called.
* @param refreshThreshold The threshold below which, if a release occurs, [onRefresh] will be
* called.
* @param refreshingOffset The offset at which the indicator will be drawn while refreshing. This
* offset corresponds to the position of the bottom of the indicator.
* offset corresponds to the position of the bottom of the indicator.
* @sample androidx.compose.material.samples.PullRefreshSample
*/
@Composable
fun rememberPullRefreshState(
refreshing: Boolean,
onRefresh: () -> Unit,
refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold,
refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset
refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset,
): PullRefreshState {
require(refreshThreshold > 0.dp) { "The refresh trigger must be greater than zero!" }
@@ -69,9 +72,8 @@ fun rememberPullRefreshState(
refreshingOffsetPx = refreshingOffset.toPx()
}
val state = remember(scope) {
PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx)
}
val state =
remember(scope) { PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx) }
SideEffect {
state.setRefreshing(refreshing)
@@ -95,134 +97,138 @@ fun rememberPullRefreshState(
*
* Should be created using [rememberPullRefreshState].
*/
class PullRefreshState internal constructor(
private val animationScope: CoroutineScope,
private val onRefreshState: State<() -> Unit>,
refreshingOffset: Float,
threshold: Float
) {
/**
* A float representing how far the user has pulled as a percentage of the refreshThreshold.
*
* If the component has not been pulled at all, progress is zero. If the pull has reached
* halfway to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has
* gone beyond the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to
* two times the refreshThreshold.
*/
val progress get() = adjustedDistancePulled / threshold
class PullRefreshState
internal constructor(
private val animationScope: CoroutineScope,
private val onRefreshState: State<() -> Unit>,
refreshingOffset: Float,
threshold: Float,
) {
/**
* A float representing how far the user has pulled as a percentage of the refreshThreshold.
*
* If the component has not been pulled at all, progress is zero. If the pull has reached halfway
* to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has gone beyond
* the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to two times the
* refreshThreshold.
*/
val progress
get() = adjustedDistancePulled / threshold
internal val refreshing get() = _refreshing
internal val position get() = _position
internal val threshold get() = _threshold
val refreshing
get() = _refreshing
private val adjustedDistancePulled by derivedStateOf { distancePulled * DragMultiplier }
val position
get() = _position
private var _refreshing by mutableStateOf(false)
private var _position by mutableStateOf(0f)
private var distancePulled by mutableStateOf(0f)
private var _threshold by mutableStateOf(threshold)
private var _refreshingOffset by mutableStateOf(refreshingOffset)
val threshold
get() = _threshold
internal fun onPull(pullDelta: Float): Float {
if (_refreshing) return 0f // Already refreshing, do nothing.
private val adjustedDistancePulled by derivedStateOf { distancePulled * DRAG_MULTIPLIER }
val newOffset = (distancePulled + pullDelta).coerceAtLeast(0f)
val dragConsumed = newOffset - distancePulled
distancePulled = newOffset
_position = calculateIndicatorPosition()
return dragConsumed
}
private var _refreshing by mutableStateOf(false)
private var _position by mutableStateOf(0f)
private var distancePulled by mutableStateOf(0f)
private var _threshold by mutableStateOf(threshold)
private var refreshingOffsetState by mutableStateOf(refreshingOffset)
internal fun onRelease(velocity: Float): Float {
if (refreshing) return 0f // Already refreshing, do nothing
internal fun onPull(pullDelta: Float): Float {
if (_refreshing) return 0f // Already refreshing, do nothing.
if (adjustedDistancePulled > threshold) {
onRefreshState.value()
val newOffset = (distancePulled + pullDelta).coerceAtLeast(0f)
val dragConsumed = newOffset - distancePulled
distancePulled = newOffset
_position = calculateIndicatorPosition()
return dragConsumed
}
animateIndicatorTo(0f)
val consumed = when {
// We are flinging without having dragged the pull refresh (for example a fling inside
// a list) - don't consume
distancePulled == 0f -> 0f
// If the velocity is negative, the fling is upwards, and we don't want to prevent the
// the list from scrolling
velocity < 0f -> 0f
// We are showing the indicator, and the fling is downwards - consume everything
else -> velocity
}
distancePulled = 0f
return consumed
}
internal fun setRefreshing(refreshing: Boolean) {
if (_refreshing != refreshing) {
_refreshing = refreshing
internal fun onRelease(velocity: Float): Float {
if (refreshing) return 0f // Already refreshing, do nothing
if (adjustedDistancePulled > threshold) {
onRefreshState.value()
}
animateIndicatorTo(0f)
val consumed =
when {
// We are flinging without having dragged the pull refresh (for example a fling inside
// a list) - don't consume
distancePulled == 0f -> 0f
// If the velocity is negative, the fling is upwards, and we don't want to prevent the
// the list from scrolling
velocity < 0f -> 0f
// We are showing the indicator, and the fling is downwards - consume everything
else -> velocity
}
distancePulled = 0f
animateIndicatorTo(if (refreshing) _refreshingOffset else 0f)
return consumed
}
}
internal fun setThreshold(threshold: Float) {
_threshold = threshold
}
internal fun setRefreshingOffset(refreshingOffset: Float) {
if (_refreshingOffset != refreshingOffset) {
_refreshingOffset = refreshingOffset
if (refreshing) animateIndicatorTo(refreshingOffset)
}
}
// Make sure to cancel any existing animations when we launch a new one. We use this instead of
// Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra
// overhead of running through the animation pipeline instead of directly mutating the state.
private val mutatorMutex = MutatorMutex()
private fun animateIndicatorTo(offset: Float) = animationScope.launch {
mutatorMutex.mutate {
animate(initialValue = _position, targetValue = offset) { value, _ ->
_position = value
internal fun setRefreshing(refreshing: Boolean) {
if (_refreshing != refreshing) {
_refreshing = refreshing
distancePulled = 0f
animateIndicatorTo(if (refreshing) refreshingOffsetState else 0f)
}
}
}
private fun calculateIndicatorPosition(): Float = when {
// If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
adjustedDistancePulled <= threshold -> adjustedDistancePulled
else -> {
// How far beyond the threshold pull has gone, as a percentage of the threshold.
val overshootPercent = abs(progress) - 1.0f
// Limit the overshoot to 200%. Linear between 0 and 200.
val linearTension = overshootPercent.coerceIn(0f, 2f)
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
val tensionPercent = linearTension - linearTension.pow(2) / 4
// The additional offset beyond the threshold.
val extraOffset = threshold * tensionPercent
threshold + extraOffset
internal fun setThreshold(threshold: Float) {
_threshold = threshold
}
}
}
/**
* Default parameter values for [rememberPullRefreshState].
*/
internal fun setRefreshingOffset(refreshingOffset: Float) {
if (refreshingOffsetState != refreshingOffset) {
refreshingOffsetState = refreshingOffset
if (refreshing) animateIndicatorTo(refreshingOffset)
}
}
// Make sure to cancel any existing animations when we launch a new one. We use this instead of
// Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra
// overhead of running through the animation pipeline instead of directly mutating the state.
private val mutatorMutex = MutatorMutex()
private fun animateIndicatorTo(offset: Float) =
animationScope.launch {
mutatorMutex.mutate {
animate(initialValue = _position, targetValue = offset) { value, _ -> _position = value }
}
}
private fun calculateIndicatorPosition(): Float =
when {
// If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
adjustedDistancePulled <= threshold -> adjustedDistancePulled
else -> {
// How far beyond the threshold pull has gone, as a percentage of the threshold.
val overshootPercent = abs(progress) - 1.0f
// Limit the overshoot to 200%. Linear between 0 and 200.
val linearTension = overshootPercent.coerceIn(0f, 2f)
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
val tensionPercent = linearTension - linearTension.pow(2) / 4
// The additional offset beyond the threshold.
val extraOffset = threshold * tensionPercent
threshold + extraOffset
}
}
}
/** Default parameter values for [rememberPullRefreshState]. */
object PullRefreshDefaults {
/**
* If the indicator is below this threshold offset when it is released, a refresh
* will be triggered.
* If the indicator is below this threshold offset when it is released, a refresh will be
* triggered.
*/
val RefreshThreshold = 80.dp
/**
* The offset at which the indicator should be rendered whilst a refresh is occurring.
*/
/** The offset at which the indicator should be rendered whilst a refresh is occurring. */
val RefreshingOffset = 56.dp
}
/**
* The distance pulled is multiplied by this value to give us the adjusted distance pulled, which
* is used in calculating the indicator position (when the adjusted distance pulled is less than
* the refresh threshold, it is the indicator position, otherwise the indicator position is
* derived from the progress).
* The distance pulled is multiplied by this value to give us the adjusted distance pulled, which is
* used in calculating the indicator position (when the adjusted distance pulled is less than the
* refresh threshold, it is the indicator position, otherwise the indicator position is derived from
* the progress).
*/
private const val DragMultiplier = 0.5f
private const val DRAG_MULTIPLIER = 0.5f

View File

@@ -1,60 +1,105 @@
package com.vitorpamplona.amethyst
import android.app.Application
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
import android.util.Log
import coil.ImageLoader
import com.vitorpamplona.amethyst.service.playback.VideoCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlin.time.measureTimedValue
class Amethyst : Application() {
val videoCache: VideoCache by lazy {
val newCache = VideoCache()
newCache.initFileCache(instance)
newCache
}
override fun onCreate() {
super.onCreate()
instance = this
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.build()
)
StrictMode.setVmPolicy(
VmPolicy.Builder()
.detectAll()
.penaltyLog()
.build()
)
}
GlobalScope.launch(Dispatchers.IO) {
val (value, elapsed) = measureTimedValue {
// initializes the video cache in a thread
videoCache
}
Log.d("Rendering Metrics", "VideoCache initialized in $elapsed")
}
}
fun imageLoaderBuilder(): ImageLoader.Builder {
return ImageLoader.Builder(applicationContext).diskCache {
SingletonDiskCache.get(applicationContext)
}
}
companion object {
lateinit var instance: Amethyst
private set
}
}
/**
* Copyright (c) 2023 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.Application
import android.content.Context
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.playback.VideoCache
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.io.File
import kotlin.time.measureTimedValue
class Amethyst : Application() {
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun onTerminate() {
super.onTerminate()
applicationIOScope.cancel()
}
val videoCache: VideoCache by lazy {
val newCache = VideoCache()
newCache.initFileCache(this)
newCache
}
private val imageCache: DiskCache by lazy {
DiskCache.Builder()
.directory(applicationContext.safeCacheDir.resolve("image_cache"))
.maxSizePercent(0.2)
.maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB
.build()
}
override fun onCreate() {
super.onCreate()
instance = this
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
ThreadPolicy.Builder().detectAll().penaltyLog().build(),
)
StrictMode.setVmPolicy(
VmPolicy.Builder().detectAll().penaltyLog().build(),
)
}
GlobalScope.launch(Dispatchers.IO) {
val (value, elapsed) =
measureTimedValue {
// initializes the video cache in a thread
videoCache
}
Log.d("Rendering Metrics", "VideoCache initialized in $elapsed")
}
}
fun imageLoaderBuilder(): ImageLoader.Builder {
return ImageLoader.Builder(applicationContext).diskCache { imageCache }
}
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences {
return EncryptedStorage.preferences(instance, npub)
}
companion object {
lateinit var instance: Amethyst
private set
}
}
internal val Context.safeCacheDir: File
get() {
val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" }
return cacheDir.apply { mkdirs() }
}

View File

@@ -1,30 +1,54 @@
/**
* Copyright (c) 2023 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.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
object EncryptedStorage {
private const val PREFERENCES_NAME = "secret_keeper"
class EncryptedStorage {
companion object {
private const val PREFERENCES_NAME = "secret_keeper"
// returns the preferences for each account or a global file if null.
fun prefsFileName(npub: String? = null): String {
return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
}
// returns the preferences for each account or a global file if null.
fun prefsFileName(npub: String? = null): String {
return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
}
fun preferences(npub: String? = null): EncryptedSharedPreferences {
val context = Amethyst.instance
val masterKey: MasterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
fun preferences(
applicationContext: Context,
npub: String? = null,
): EncryptedSharedPreferences {
val masterKey: MasterKey =
MasterKey.Builder(applicationContext, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val preferencesName = prefsFileName(npub)
return EncryptedSharedPreferences.create(
context,
preferencesName,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
) as EncryptedSharedPreferences
return EncryptedSharedPreferences.create(
applicationContext,
prefsFileName(npub),
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
) as EncryptedSharedPreferences
}
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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.annotation.SuppressLint
@@ -15,21 +35,29 @@ import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.Nip47URI
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.model.parseBooleanType
import com.vitorpamplona.amethyst.model.parseConnectivityType
import com.vitorpamplona.amethyst.model.parseThemeType
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.encoders.toNpub
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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Locale
@@ -44,7 +72,7 @@ private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
data class AccountInfo(
val npub: String,
val hasPrivKey: Boolean,
val loggedInWithExternalSigner: Boolean
val loggedInWithExternalSigner: Boolean,
)
private object PrefKeys {
@@ -52,9 +80,6 @@ private object PrefKeys {
const val SAVED_ACCOUNTS = "all_saved_accounts"
const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey"
const val FOLLOWING_CHANNELS = "following_channels"
const val FOLLOWING_COMMUNITIES = "following_communities"
const val HIDDEN_USERS = "hidden_users"
const val RELAYS = "relays"
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
const val LANGUAGE_PREFS = "languagePreferences"
@@ -86,70 +111,78 @@ private object PrefKeys {
const val AUTOMATICALLY_HIDE_NAV_BARS = "automatically_hide_nav_bars"
const val LOGIN_WITH_EXTERNAL_SIGNER = "login_with_external_signer"
const val AUTOMATICALLY_SHOW_PROFILE_PICTURE = "automatically_show_profile_picture"
const val SIGNER_PACKAGE_NAME = "signer_package_name"
const val ALL_ACCOUNT_INFO = "all_saved_accounts_info"
const val SHARED_SETTINGS = "shared_settings"
}
object LocalPreferences {
private const val comma = ","
private const val COMMA = ","
private var _currentAccount: String? = null
private var _savedAccounts: List<AccountInfo>? = null
private var currentAccount: String? = null
private var savedAccounts: List<AccountInfo>? = null
private var cachedAccounts: MutableMap<String, Account?> = mutableMapOf()
suspend fun currentAccount(): String? {
if (_currentAccount == null) {
_currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
if (currentAccount == null) {
currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
}
return _currentAccount
return currentAccount
}
private suspend fun updateCurrentAccount(npub: String) {
if (_currentAccount != npub) {
_currentAccount = npub
private fun updateCurrentAccount(npub: String?) {
if (npub == null) {
currentAccount = null
encryptedPreferences().edit().clear().apply()
} else if (currentAccount != npub) {
currentAccount = npub
encryptedPreferences().edit().apply {
putString(PrefKeys.CURRENT_ACCOUNT, npub)
}.apply()
encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, npub) }.apply()
}
}
private fun savedAccounts(): List<AccountInfo> {
if (_savedAccounts == null) {
if (savedAccounts == null) {
with(encryptedPreferences()) {
val newSystemOfAccounts = getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
Event.mapper.readValue<List<AccountInfo>>(it)
}
if (newSystemOfAccounts != null && newSystemOfAccounts.isNotEmpty()) {
_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()
)
val newSystemOfAccounts =
getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
Event.mapper.readValue<List<AccountInfo>>(it)
}
_savedAccounts = migrated
if (newSystemOfAccounts != null && newSystemOfAccounts.isNotEmpty()) {
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
}
}
}
return _savedAccounts!!
return savedAccounts!!
}
private suspend fun updateSavedAccounts(accounts: List<AccountInfo>) = withContext(Dispatchers.IO) {
if (_savedAccounts != accounts) {
_savedAccounts = accounts
private suspend fun updateSavedAccounts(accounts: List<AccountInfo>) =
withContext(Dispatchers.IO) {
if (savedAccounts != accounts) {
savedAccounts = accounts
encryptedPreferences().edit().apply {
putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(accounts))
}.apply()
encryptedPreferences()
.edit()
.apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(accounts)) }
.apply()
}
}
}
private val prefsDirPath: String
get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/"
@@ -159,32 +192,28 @@ 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.loginWithExternalSigner
)
updateCurrentAccount(npub)
addAccount(accInfo)
}
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)
}
suspend fun switchToAccount(accountInfo: AccountInfo) = withContext(Dispatchers.IO) {
updateCurrentAccount(accountInfo.npub)
}
suspend fun switchToAccount(accountInfo: AccountInfo) = withContext(Dispatchers.IO) { updateCurrentAccount(accountInfo.npub) }
/**
* Removes the account from the app level shared preferences
*/
/** 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)
}
/**
* Deletes the npub-specific shared preference file
*/
/** Deletes the npub-specific shared preference file */
private fun deleteUserPreferenceFile(npub: String) {
checkNotInMainThread()
@@ -200,34 +229,36 @@ object LocalPreferences {
checkNotInMainThread()
return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) {
val preferenceFile = if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub"
val preferenceFile =
if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub"
Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
} else {
return EncryptedStorage.preferences(npub)
return Amethyst.instance.encryptedStorage(npub)
}
}
/**
* Clears the preferences for a given npub, deletes the preferences xml file,
* and switches the user to the first account in the list if it exists
* Clears the preferences for a given npub, deletes the preferences xml file, and switches the
* user to the first account in the list if it exists
*
* We need to use `commit()` to write changes to disk and release the file
* lock so that it can be deleted. If we use `apply()` there is a race
* condition and the file will probably not be deleted
* We need to use `commit()` to write changes to disk and release the file lock so that it can be
* deleted. If we use `apply()` there is a race condition and the file will probably not be
* deleted
*/
@SuppressLint("ApplySharedPref")
suspend fun updatePrefsForLogout(accountInfo: AccountInfo) = withContext(Dispatchers.IO) {
val userPrefs = encryptedPreferences(accountInfo.npub)
userPrefs.edit().clear().commit()
removeAccount(accountInfo)
deleteUserPreferenceFile(accountInfo.npub)
suspend fun updatePrefsForLogout(accountInfo: AccountInfo) =
withContext(Dispatchers.IO) {
val userPrefs = encryptedPreferences(accountInfo.npub)
userPrefs.edit().clear().commit()
removeAccount(accountInfo)
deleteUserPreferenceFile(accountInfo.npub)
if (savedAccounts().isEmpty()) {
encryptedPreferences().edit().clear().apply()
} else if (currentAccount() == accountInfo.npub) {
updateCurrentAccount(savedAccounts().elementAt(0).npub)
if (savedAccounts().isEmpty()) {
updateCurrentAccount(null)
} else if (currentAccount() == accountInfo.npub) {
updateCurrentAccount(savedAccounts().elementAt(0).npub)
}
}
}
suspend fun updatePrefsForLogin(account: Account) {
setCurrentAccount(account)
@@ -238,56 +269,80 @@ object LocalPreferences {
return savedAccounts()
}
suspend fun saveToEncryptedStorage(account: Account) = withContext(Dispatchers.IO) {
checkNotInMainThread()
suspend fun saveToEncryptedStorage(account: Account) =
withContext(Dispatchers.IO) {
checkNotInMainThread()
val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
prefs.edit().apply {
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.loginWithExternalSigner)
if (account.loginWithExternalSigner) {
remove(PrefKeys.NOSTR_PRIVKEY)
} else {
account.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
}
account.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
putStringSet(PrefKeys.FOLLOWING_CHANNELS, account.followingChannels)
putStringSet(PrefKeys.FOLLOWING_COMMUNITIES, account.followingCommunities)
putStringSet(PrefKeys.HIDDEN_USERS, account.hiddenUsers)
putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(account.localRelays))
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
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, account.defaultFileServer.name)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList)
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList)
putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, account.defaultDiscoveryFollowList)
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, Event.mapper.writeValueAsString(account.zapPaymentRequest))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.mapper.writeValueAsString(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, account.hideNIP24WarningDialog)
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))
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)
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),
)
putString(
PrefKeys.LATEST_CONTACT_LIST,
Event.mapper.writeValueAsString(account.backupContactList),
)
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, account.hideNIP24WarningDialog)
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),
)
if (account.showSensitiveContent == null) {
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
} else {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
}
}.apply()
}
if (account.showSensitiveContent == null) {
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
} else {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
}
}
.apply()
}
suspend fun loadCurrentAccountFromEncryptedStorage(): Account? {
val acc = loadCurrentAccountFromEncryptedStorage(currentAccount())
acc?.registerObservers()
return acc
return currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
}
suspend fun migrateOldSharedSettings(): Settings? {
@@ -299,7 +354,10 @@ object LocalPreferences {
return null
}
suspend fun saveSharedSettings(sharedSettings: Settings, prefs: SharedPreferences = encryptedPreferences()) {
suspend fun saveSharedSettings(
sharedSettings: Settings,
prefs: SharedPreferences = encryptedPreferences(),
) {
with(prefs.edit()) {
putString(PrefKeys.SHARED_SETTINGS, Event.mapper.writeValueAsString(sharedSettings))
apply()
@@ -309,11 +367,14 @@ object LocalPreferences {
suspend fun loadSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? {
with(prefs) {
return try {
getString(PrefKeys.SHARED_SETTINGS, "{}")?.let {
Event.mapper.readValue<Settings>(it)
}
getString(PrefKeys.SHARED_SETTINGS, "{}")?.let { Event.mapper.readValue<Settings>(it) }
} catch (e: Throwable) {
Log.w("LocalPreferences", "Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}", e)
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}",
e,
)
e.printStackTrace()
null
}
@@ -327,39 +388,45 @@ object LocalPreferences {
return null
}
val automaticallyShowImages = if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) {
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false))
} else {
ConnectivityType.ALWAYS
}
val automaticallyShowImages =
if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) {
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false))
} else {
ConnectivityType.ALWAYS
}
val automaticallyStartPlayback = if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false))
} else {
ConnectivityType.ALWAYS
}
val automaticallyShowUrlPreview = if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) {
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false))
} else {
ConnectivityType.ALWAYS
}
val automaticallyHideNavigationBars = if (contains(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS)) {
parseBooleanType(getBoolean(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS, false))
} else {
BooleanType.ALWAYS
}
val automaticallyStartPlayback =
if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false))
} else {
ConnectivityType.ALWAYS
}
val automaticallyShowUrlPreview =
if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) {
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false))
} else {
ConnectivityType.ALWAYS
}
val automaticallyHideNavigationBars =
if (contains(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS)) {
parseBooleanType(getBoolean(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS, false))
} else {
BooleanType.ALWAYS
}
val automaticallyShowProfilePictures = if (contains(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE)) {
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE, false))
} else {
ConnectivityType.ALWAYS
}
val automaticallyShowProfilePictures =
if (contains(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE)) {
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE, false))
} else {
ConnectivityType.ALWAYS
}
val themeType = if (contains(PrefKeys.THEME)) {
parseThemeType(getInt(PrefKeys.THEME, ThemeType.SYSTEM.screenCode))
} else {
ThemeType.SYSTEM
}
val themeType =
if (contains(PrefKeys.THEME)) {
parseThemeType(getInt(PrefKeys.THEME, ThemeType.SYSTEM.screenCode))
} else {
ThemeType.SYSTEM
}
return Settings(
themeType,
@@ -370,138 +437,224 @@ object LocalPreferences {
automaticallyHideNavigationBars,
automaticallyShowProfilePictures,
false,
false
false,
)
}
}
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String?): Account? = withContext(Dispatchers.IO) {
checkNotInMainThread()
val mutex = Mutex()
return@withContext with(encryptedPreferences(npub)) {
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 followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
val followingCommunities = getStringSet(PrefKeys.FOLLOWING_COMMUNITIES, null) ?: setOf()
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
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 translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultNotificationFollowList = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
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
val defaultFileServer = getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName ->
ServersAvailable.values().firstOrNull() { it.name == serverName }
} ?: ServersAvailable.NOSTR_BUILD
val zapPaymentRequestServer = try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
Event.mapper.readValue<Nip47URI?>(it)
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): Account? =
withContext(Dispatchers.IO) {
mutex.withLock {
if (cachedAccounts.containsKey(npub)) {
return@withContext cachedAccounts.get(npub)
}
} catch (e: Throwable) {
Log.w("LocalPreferences", "Error Decoding Zap Payment Request Server ${getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)}", e)
e.printStackTrace()
null
}
val latestContactList = try {
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
println("Decoding Contact List: " + it)
if (it != null) {
Event.fromJson(it) as ContactListEvent?
val account = innerLoadCurrentAccountFromEncryptedStorage(npub)
account?.registerObservers()
cachedAccounts.put(npub, account)
return@withContext account
}
}
suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): Account? =
withContext(Dispatchers.IO) {
checkNotInMainThread()
return@withContext with(encryptedPreferences(npub)) {
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 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 translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
val defaultHomeFollowList =
getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
val defaultStoriesFollowList =
getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultNotificationFollowList =
getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
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
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 zapPaymentRequestServer =
try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
Event.mapper.readValue<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()
null
}
val latestContactList =
try {
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
println("Decoding Contact List: " + it)
if (it != null) {
Event.fromJson(it) as ContactListEvent?
} 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 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 hideNIP24WarningDialog = getBoolean(PrefKeys.HIDE_NIP_24_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)
} else {
null
}
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()
}
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 account =
Account(
keyPair = keyPair,
signer = signer,
localRelays = localRelays,
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,
hideNIP24WarningDialog = hideNIP24WarningDialog,
backupContactList = latestContactList,
proxy = proxy,
proxyPort = proxyPort,
showSensitiveContent = showSensitiveContent,
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
lastReadPerRoute = lastReadPerRoute,
)
// Loads from DB
account.userProfile()
withContext(Dispatchers.Main) {
// Loads Live Objects
account.userProfile().live()
}
} catch (e: Throwable) {
Log.w("LocalPreferences", "Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}", e)
null
return@with account
}
val languagePreferences = try {
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
Event.mapper.readValue<Map<String, String>?>(it)
} ?: mapOf()
} catch (e: Throwable) {
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 hideNIP24WarningDialog = getBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, false)
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
val showSensitiveContent = if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
} else {
null
}
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) {
Log.w("LocalPreferences", "Error Decoding Last Read per route ${getString(PrefKeys.LAST_READ_PER_ROUTE, null)}", e)
e.printStackTrace()
mapOf()
}
return@with Account(
keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
followingChannels = followingChannels,
followingCommunities = followingCommunities,
hiddenUsers = hiddenUsers,
localRelays = localRelays,
dontTranslateFrom = dontTranslateFrom,
languagePreferences = languagePreferences,
translateTo = translateTo,
zapAmountChoices = zapAmountChoices,
reactionChoices = reactionChoices,
defaultZapType = defaultZapType,
defaultFileServer = defaultFileServer,
defaultHomeFollowList = defaultHomeFollowList,
defaultStoriesFollowList = defaultStoriesFollowList,
defaultNotificationFollowList = defaultNotificationFollowList,
defaultDiscoveryFollowList = defaultDiscoveryFollowList,
zapPaymentRequest = zapPaymentRequestServer,
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP24WarningDialog = hideNIP24WarningDialog,
backupContactList = latestContactList,
proxy = proxy,
proxyPort = proxyPort,
showSensitiveContent = showSensitiveContent,
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
lastReadPerRoute = lastReadPerRoute,
loginWithExternalSigner = loginWithExternalSigner
)
}
}
}

View File

@@ -1,11 +0,0 @@
package com.vitorpamplona.amethyst
object OptOutFromFilters {
var warnAboutPostsWithReports: Boolean = true
var filterSpamFromStrangers: Boolean = true
fun start(warnAboutReports: Boolean, filterSpam: Boolean) {
warnAboutPostsWithReports = warnAboutReports
filterSpamFromStrangers = filterSpam
}
}

View File

@@ -1,18 +1,36 @@
/**
* Copyright (c) 2023 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.content.Context
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.disk.DiskCache
import coil.util.DebugLogger
import coil.size.Precision
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
@@ -30,27 +48,33 @@ import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
import com.vitorpamplona.quartz.encoders.bechToBytes
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.encoders.toHexKey
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.launch
import java.io.File
object ServiceManager {
var shouldPauseService: Boolean = true // to not open amber in a loop trying to use auth relays and registering for notifications
private var isStarted: Boolean = false // to not open amber in a loop trying to use auth relays and registering for notifications
@Stable
class ServiceManager {
private var isStarted: Boolean =
false // to not open amber in a loop trying to use auth relays and registering for notifications
private var account: Account? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var collectorJob: Job? = null
private fun start(account: Account) {
this.account = account
ExternalSignerUtils.account = account
start()
}
@Synchronized
fun start() {
private fun start() {
Log.d("ServiceManager", "Pre Starting Relay Services $isStarted $account")
if (isStarted && account != null) {
return
@@ -60,32 +84,57 @@ object ServiceManager {
val myAccount = account
// Resets Proxy Use
HttpClient.start(account)
OptOutFromFilters.start(account?.warnAboutPostsWithReports ?: true, account?.filterSpamFromStrangers ?: true)
HttpClientManager.setDefaultProxy(account?.proxy)
LocalCache.antiSpam.active = account?.filterSpamFromStrangers ?: true
Coil.setImageLoader {
Amethyst.instance.imageLoaderBuilder().components {
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
}.logger(DebugLogger())
.okHttpClient { HttpClient.getHttpClient() }
Amethyst.instance
.imageLoaderBuilder()
.components {
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
} // .logger(DebugLogger())
.okHttpClient { HttpClientManager.getHttpClient() }
.precision(Precision.INEXACT)
.respectCacheHeaders(false)
.build()
}
if (myAccount != null) {
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
val relaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
Log.d("Relay", "Service Manager Connect Connecting ${relaySet.size}")
Client.reconnect(relaySet)
collectorJob?.cancel()
collectorJob = null
collectorJob =
scope.launch {
myAccount.userProfile().flow().relays.stateFlow.collect {
if (isStarted) {
val newRelaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
Client.reconnect(newRelaySet, 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
ImageUploader.account = myAccount
// Notification Elements
NostrHomeDataSource.start()
@@ -105,28 +154,31 @@ object ServiceManager {
}
}
fun pause() {
private fun pause() {
Log.d("ServiceManager", "Pausing Relay Services")
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
NostrChatroomDataSource.stop()
NostrChatroomListDataSource.stop()
NostrDiscoveryDataSource.stop()
collectorJob?.cancel()
collectorJob = null
NostrCommunityDataSource.stop()
NostrHashtagDataSource.stop()
NostrGeohashDataSource.stop()
NostrSearchEventOrUserDataSource.stop()
NostrSingleChannelDataSource.stop()
NostrSingleEventDataSource.stop()
NostrSingleUserDataSource.stop()
NostrThreadDataSource.stop()
NostrUserProfileDataSource.stop()
NostrVideoDataSource.stop()
NostrAccountDataSource.stopSync()
NostrHomeDataSource.stopSync()
NostrChannelDataSource.stopSync()
NostrChatroomDataSource.stopSync()
NostrChatroomListDataSource.stopSync()
NostrDiscoveryDataSource.stopSync()
Client.disconnect()
NostrCommunityDataSource.stopSync()
NostrHashtagDataSource.stopSync()
NostrGeohashDataSource.stopSync()
NostrSearchEventOrUserDataSource.stopSync()
NostrSingleChannelDataSource.stopSync()
NostrSingleEventDataSource.stopSync()
NostrSingleUserDataSource.stopSync()
NostrThreadDataSource.stopSync()
NostrUserProfileDataSource.stopSync()
NostrVideoDataSource.stopSync()
Client.reconnect(null)
isStarted = false
}
@@ -137,9 +189,8 @@ object ServiceManager {
fun trimMemory() {
LocalCache.cleanObservers()
val accounts = LocalPreferences.allSavedAccounts().mapNotNull {
decodePublicKeyAsHexOrNull(it.npub)
}.toSet()
val accounts =
LocalPreferences.allSavedAccounts().mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
account?.let {
LocalCache.pruneOldAndHiddenMessages(it)
@@ -153,42 +204,47 @@ object ServiceManager {
}
}
// This method keeps the pause/start in a Syncronized block to
// avoid concurrent pauses and starts.
@Synchronized
fun forceRestart(
account: Account? = null,
start: Boolean = true,
pause: Boolean = true,
) {
if (pause) {
pause()
}
if (start) {
if (account != null) {
start(account)
} else {
start()
}
}
}
fun restartIfDifferentAccount(account: Account) {
if (this.account != account) {
pause()
start(account)
forceRestart(account, true, true)
}
}
fun forceRestartIfItShould() {
if (shouldPauseService) {
pause()
start()
}
fun forceRestart() {
forceRestart(null, true, true)
}
fun justStart() {
forceRestart(null, true, false)
}
fun pauseForGood() {
forceRestart(null, false, true)
}
fun pauseForGoodAndClearAccount() {
account = null
forceRestart(null, false, true)
}
}
object SingletonDiskCache {
private const val DIRECTORY = "image_cache"
private var instance: DiskCache? = null
@Synchronized
fun get(context: Context): DiskCache {
return instance ?: run {
// Create the singleton disk cache instance.
DiskCache.Builder()
.directory(context.safeCacheDir.resolve(DIRECTORY))
.maxSizePercent(0.2)
.maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB
.build()
.also { instance = it }
}
}
}
internal val Context.safeCacheDir: File
get() {
val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" }
return cacheDir.apply { mkdirs() }
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,29 @@
/**
* Copyright (c) 2023 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 android.util.LruCache
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.OptOutFromFilters
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
@@ -19,10 +38,15 @@ class AntiSpamFilter {
val recentMessages = LruCache<Int, String>(1000)
val spamMessages = LruCache<Int, Spammer>(1000)
fun isSpam(event: Event, relay: Relay?): Boolean {
var active: Boolean = true
fun isSpam(
event: Event,
relay: Relay?,
): Boolean {
checkNotInMainThread()
if (!OptOutFromFilters.filterSpamFromStrangers) return false
if (!active) return false
val idHex = event.id
@@ -30,8 +54,10 @@ class AntiSpamFilter {
// The idea here is to avoid considering repeated "GM" messages spam.
if (event.content.length < 50) return false
// if the message is actually short but because it cites a user/event, the nostr: string is really long, make it ok.
// The idea here is to avoid considering repeated "@Bot, command" messages spam, while still blocking repeated "lnbc..." invoices or fishing urls
// if the message is actually short but because it cites a user/event, the nostr: string is
// really long, make it ok.
// The idea here is to avoid considering repeated "@Bot, command" messages spam, while still
// blocking repeated "lnbc..." invoices or fishing urls
if (event.content.length < 180 && Nip19.nip19regex.matcher(event.content).find()) return false
// double list strategy:
@@ -40,9 +66,17 @@ class AntiSpamFilter {
// Considers tags so that same replies to different people don't count.
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) {
Log.w("Potential SPAM Message for sharing", "${Nip19.createNEvent(event.id, event.pubKey, event.kind, null)}")
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}")
if (
(recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null
) {
Log.w(
"Potential SPAM Message for sharing",
"${Nip19.createNEvent(event.id, event.pubKey, event.kind, null)}",
)
Log.w(
"Potential SPAM Message",
"${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}",
)
// Log down offenders
logOffender(hash, event)
@@ -58,7 +92,10 @@ class AntiSpamFilter {
}
@Synchronized
private fun logOffender(hashCode: Int, event: Event) {
private fun logOffender(
hashCode: Int,
event: Event,
) {
if (spamMessages.get(hashCode) == null) {
spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id)))
} else {
@@ -72,14 +109,13 @@ class AntiSpamFilter {
@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() {
bundler.invalidate {
checkNotInMainThread()
if (hasActiveObservers()) {

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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
@@ -19,12 +39,13 @@ import java.util.concurrent.ConcurrentHashMap
class PublicChatChannel(idHex: String) : Channel(idHex) {
var info = ChannelCreateEvent.ChannelData(null, null, null)
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
this.creator = creator
fun updateChannelInfo(
creator: User,
channelInfo: ChannelCreateEvent.ChannelData,
updatedAt: Long,
) {
this.info = channelInfo
this.updatedMetadataAt = updatedAt
live.invalidateData()
super.updateChannelInfo(creator, updatedAt)
}
override fun toBestDisplayName(): String {
@@ -41,8 +62,7 @@ class PublicChatChannel(idHex: String) : Channel(idHex) {
}
override fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info.name, info.about)
.filter { it.contains(prefix, true) }.isNotEmpty()
return listOfNotNull(info.name, info.about).filter { it.contains(prefix, true) }.isNotEmpty()
}
}
@@ -51,10 +71,16 @@ class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
var info: LiveActivitiesEvent? = null
override fun idNote() = address.toNAddr()
override fun idDisplayNote() = idNote().toShortenHex()
fun address() = address
fun updateChannelInfo(creator: User, channelInfo: LiveActivitiesEvent, updatedAt: Long) {
fun updateChannelInfo(
creator: User,
channelInfo: LiveActivitiesEvent,
updatedAt: Long,
) {
this.info = channelInfo
super.updateChannelInfo(creator, updatedAt)
}
@@ -73,7 +99,8 @@ class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
override fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info?.title(), info?.summary())
.filter { it.contains(prefix, true) }.isNotEmpty()
.filter { it.contains(prefix, true) }
.isNotEmpty()
}
}
@@ -86,7 +113,9 @@ abstract class Channel(val idHex: String) {
val notes = ConcurrentHashMap<HexKey, Note>()
open fun id() = Hex.decode(idHex)
open fun idNote() = id().toNote()
open fun idDisplayNote() = idNote().toShortenHex()
open fun toBestDisplayName(): String {
@@ -105,7 +134,10 @@ abstract class Channel(val idHex: String) {
return creator?.profilePicture()
}
open fun updateChannelInfo(creator: User, updatedAt: Long) {
open fun updateChannelInfo(
creator: User,
updatedAt: Long,
) {
this.creator = creator
this.updatedMetadataAt = updatedAt
@@ -130,18 +162,17 @@ abstract class Channel(val idHex: String) {
val live: ChannelLiveData = ChannelLiveData(this)
fun pruneOldAndHiddenMessages(account: Account): Set<Note> {
val important = notes.values
.filter { it.author?.let { it1 -> account.isHidden(it1) } == false }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
.take(1000)
.toSet()
val important =
notes.values
.filter { it.author?.let { it1 -> account.isHidden(it1) } == false }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
.take(1000)
.toSet()
val toBeRemoved = notes.values.filter { it !in important }.toSet()
toBeRemoved.forEach {
notes.remove(it.idHex)
}
toBeRemoved.forEach { notes.remove(it.idHex) }
return toBeRemoved
}
@@ -154,7 +185,7 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate() {
bundler.invalidate {
checkNotInMainThread()
if (hasActiveObservers()) {
postValue(ChannelState(channel))

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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
@@ -34,10 +54,14 @@ class Chatroom() {
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
roomMessages.filter { it.event?.subject() != null }.sortedBy { it.createdAt() }.lastOrNull()?.let {
subject = it.event?.subject()
subjectCreatedAt = it.createdAt()
}
roomMessages
.filter { it.event?.subject() != null }
.sortedBy { it.createdAt() }
.lastOrNull()
?.let {
subject = it.event?.subject()
subjectCreatedAt = it.createdAt()
}
}
}
@@ -48,13 +72,14 @@ class Chatroom() {
fun pruneMessagesToTheLatestOnly(): Set<Note> {
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
val toKeep = if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) {
// Recent messages, keep last 100
sorted.take(100).toSet()
} else {
// Old messages, keep the last one.
sorted.take(1).toSet()
} + sorted.filter { it.liveSet?.isInUse() ?: false }
val toKeep =
if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) {
// Recent messages, keep last 100
sorted.take(100).toSet()
} else {
// Old messages, keep the last one.
sorted.take(1).toSet()
} + sorted.filter { it.liveSet?.isInUse() ?: false }
val toRemove = roomMessages.minus(toKeep)
roomMessages = toKeep

View File

@@ -1,26 +1,173 @@
/**
* Copyright (c) 2023 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.foundation.layout.padding
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
fun checkForHashtagWithIcon(tag: String, primary: Color): HashtagIcon? {
fun checkForHashtagWithIcon(
tag: String,
primary: Color,
): HashtagIcon? {
return when (tag.lowercase()) {
"bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"nostr", "nostrich", "nostriches", "thenostr" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
"onyx" -> HashtagIcon(R.drawable.black_heart, "Onyx", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"cashu", "ecash", "nut", "nuts", "deeznuts" -> HashtagIcon(R.drawable.cashu, "Cashu", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
"coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
"grownostr", "gardening", "garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
"footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
"tunestr", "music", "nowplaying" -> HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(0.dp, 3.dp, 0.dp, 1.dp))
"weed", "weedstr", "420", "cannabis", "marijuana" -> HashtagIcon(R.drawable.weed, "Weed", Color.Unspecified, Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp))
"bitcoin",
"btc",
"timechain",
"bitcoiner",
"bitcoiners",
->
HashtagIcon(
R.drawable.ht_btc,
"Bitcoin",
Color.Unspecified,
Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp),
)
"nostr",
"nostrich",
"nostriches",
"thenostr",
->
HashtagIcon(
R.drawable.ht_nostr,
"Nostr",
Color.Unspecified,
Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp),
)
"lightning",
"lightningnetwork",
->
HashtagIcon(
R.drawable.ht_lightning,
"Lightning",
Color.Unspecified,
Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp),
)
"zap",
"zaps",
"zapper",
"zappers",
"zapping",
"zapped",
"zapathon",
"zapraiser",
"zaplife",
"zapchain",
->
HashtagIcon(
R.drawable.zap,
"Zap",
Color.Unspecified,
Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp),
)
"amethyst" ->
HashtagIcon(
R.drawable.amethyst,
"Amethyst",
Color.Unspecified,
Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp),
)
"onyx" ->
HashtagIcon(
R.drawable.black_heart,
"Onyx",
Color.Unspecified,
Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp),
)
"cashu",
"ecash",
"nut",
"nuts",
"deeznuts",
->
HashtagIcon(
R.drawable.cashu,
"Cashu",
Color.Unspecified,
Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp),
)
"plebs",
"pleb",
"plebchain",
->
HashtagIcon(
R.drawable.plebs,
"Pleb",
Color.Unspecified,
Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp),
)
"coffee",
"coffeechain",
"cafe",
->
HashtagIcon(
R.drawable.coffee,
"Coffee",
Color.Unspecified,
Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp),
)
"skullofsatoshi" ->
HashtagIcon(
R.drawable.skull,
"SkullofSatoshi",
Color.Unspecified,
Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp),
)
"grownostr",
"gardening",
"garden",
->
HashtagIcon(
R.drawable.grownostr,
"GrowNostr",
Color.Unspecified,
Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp),
)
"footstr" ->
HashtagIcon(
R.drawable.footstr,
"Footstr",
Color.Unspecified,
Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp),
)
"tunestr",
"music",
"nowplaying",
->
HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(0.dp, 3.dp, 0.dp, 1.dp))
"weed",
"weedstr",
"420",
"cannabis",
"marijuana",
->
HashtagIcon(
R.drawable.weed,
"Weed",
Color.Unspecified,
Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp),
)
else -> null
}
}
@@ -30,5 +177,5 @@ class HashtagIcon(
val icon: Int,
val description: String,
val color: Color,
val modifier: Modifier
val modifier: Modifier,
)

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.quartz.encoders.HexKey

File diff suppressed because it is too large Load Diff

View File

@@ -1,42 +1,79 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.quartz.encoders.HexKey
class ParticipantListBuilder {
private fun addFollowsThatDirectlyParticipateOnToSet(baseNote: Note, followingSet: Set<HexKey>?, set: MutableSet<User>) {
private fun addFollowsThatDirectlyParticipateOnToSet(
baseNote: Note,
followingSet: Set<HexKey>?,
set: MutableSet<User>,
) {
baseNote.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) {
set.add(author)
}
}
// Breaks these searchers down to avoid the memory use of creating multiple lists
baseNote.replies.forEach { reply ->
reply.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) {
set.add(author)
}
}
}
baseNote.boosts.forEach { boost ->
boost.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) {
set.add(author)
}
}
}
baseNote.zaps.forEach { zapPair ->
zapPair.key.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) {
set.add(author)
}
}
}
baseNote.reactions.forEach { reactionSet ->
reactionSet.value.forEach { reaction ->
reaction.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) {
set.add(author)
}
}
}
}
}
fun followsThatParticipateOnDirect(baseNote: Note?, followingSet: Set<HexKey>?): Set<User> {
fun followsThatParticipateOnDirect(
baseNote: Note?,
followingSet: Set<HexKey>?,
): Set<User> {
if (baseNote == null) return mutableSetOf()
val set = mutableSetOf<User>()
@@ -44,20 +81,19 @@ class ParticipantListBuilder {
return set
}
fun followsThatParticipateOn(baseNote: Note?, followingSet: Set<HexKey>?): Set<User> {
fun followsThatParticipateOn(
baseNote: Note?,
followingSet: Set<HexKey>?,
): Set<User> {
if (baseNote == null) return mutableSetOf()
val mySet = mutableSetOf<User>()
addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, mySet)
baseNote.replies.forEach {
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
}
baseNote.replies.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) }
baseNote.boosts.forEach {
it.replyTo?.forEach {
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
}
it.replyTo?.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) }
}
LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.values?.forEach {
@@ -67,7 +103,10 @@ class ParticipantListBuilder {
return mySet
}
fun countFollowsThatParticipateOn(baseNote: Note?, followingSet: Set<HexKey>?): Int {
fun countFollowsThatParticipateOn(
baseNote: Note?,
followingSet: Set<HexKey>?,
): Int {
if (baseNote == null) return 0
val list = followsThatParticipateOn(baseNote, followingSet)

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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
@@ -21,10 +41,11 @@ class RelayInformation(
val tags: List<String>?,
val posting_policy: String?,
val payments_url: String?,
val fees: RelayInformationFees?
val fees: RelayInformationFees?,
) {
companion object {
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
val mapper =
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
fun fromJson(json: String): RelayInformation = mapper.readValue(json, RelayInformation::class.java)
}
@@ -35,14 +56,14 @@ class RelayInformationFee(
val amount: Int?,
val unit: String?,
val period: Int?,
val kinds: List<Int>?
val kinds: List<Int>?,
)
class RelayInformationFees(
val admission: List<RelayInformationFee>?,
val subscription: List<RelayInformationFee>?,
val publication: List<RelayInformationFee>?,
val retention: List<RelayInformationFee>?
val retention: List<RelayInformationFee>?,
)
class RelayInformationLimitation(
@@ -56,5 +77,5 @@ class RelayInformationLimitation(
val max_content_length: Int?,
val min_pow_difficulty: Int?,
val auth_required: Boolean?,
val payment_required: Boolean?
val payment_required: Boolean?,
)

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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.Immutable
@@ -13,7 +33,7 @@ data class RelaySetupInfo(
val uploadCountInBytes: Int = 0,
val spamCount: Int = 0,
val feedTypes: Set<FeedType>,
val paidRelay: Boolean = false
val paidRelay: Boolean = false,
) {
val briefInfo: RelayBriefInfo = RelayBriefInfo(url)
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
}

View File

@@ -1,16 +0,0 @@
package com.vitorpamplona.amethyst.model
enum class ServersAvailable {
// IMGUR,
NOSTR_BUILD,
NOSTRIMG,
NOSTRFILES_DEV,
NOSTRCHECK_ME,
// IMGUR_NIP_94,
NOSTRIMG_NIP_94,
NOSTR_BUILD_NIP_94,
NOSTRFILES_DEV_NIP_94,
NOSTRCHECK_ME_NIP_94,
NIP95
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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
@@ -13,13 +33,13 @@ data class Settings(
val automaticallyHideNavigationBars: BooleanType = BooleanType.ALWAYS,
val automaticallyShowProfilePictures: ConnectivityType = ConnectivityType.ALWAYS,
val dontShowPushNotificationSelector: Boolean = false,
val dontAskForNotificationPermissions: Boolean = false
val dontAskForNotificationPermissions: Boolean = false,
)
enum class ThemeType(val screenCode: Int, val resourceId: Int) {
SYSTEM(0, R.string.system),
LIGHT(1, R.string.light),
DARK(2, R.string.dark)
DARK(2, R.string.dark),
}
fun parseThemeType(code: Int?): ThemeType {
@@ -36,7 +56,7 @@ fun parseThemeType(code: Int?): ThemeType {
enum class ConnectivityType(val prefCode: Boolean?, val screenCode: Int, val resourceId: Int) {
ALWAYS(null, 0, R.string.connectivity_type_always),
WIFI_ONLY(true, 1, R.string.connectivity_type_wifi_only),
NEVER(false, 2, R.string.connectivity_type_never)
NEVER(false, 2, R.string.connectivity_type_never),
}
fun parseConnectivityType(code: Boolean?): ConnectivityType {
@@ -63,7 +83,7 @@ fun parseConnectivityType(screenCode: Int): ConnectivityType {
enum class BooleanType(val prefCode: Boolean?, val screenCode: Int, val reourceId: Int) {
ALWAYS(null, 0, R.string.connectivity_type_always),
NEVER(false, 1, R.string.connectivity_type_never)
NEVER(false, 1, R.string.connectivity_type_never),
}
fun parseBooleanType(code: Boolean?): BooleanType {

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -6,15 +26,21 @@ import com.vitorpamplona.quartz.events.RepostEvent
import kotlin.time.measureTimedValue
class ThreadAssembler {
private fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
private fun searchRoot(
note: Note,
testedNotes: MutableSet<Note> = mutableSetOf(),
): Note? {
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
if (note.event is RepostEvent || note.event is GenericRepostEvent) return note
testedNotes.add(note)
val markedAsRoot = note.event?.tags()?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
val markedAsRoot =
note.event
?.tags()
?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }
?.getOrNull(1)
if (markedAsRoot != null) {
// Check to ssee if there is an error in the tag and the root has replies
if (LocalCache.getNoteIfExists(markedAsRoot)?.replyTo?.isEmpty() == true) {
@@ -26,13 +52,16 @@ class ThreadAssembler {
if (hasNoReplyTo != null) return hasNoReplyTo
// recursive
val roots = note.replyTo?.map {
if (it !in testedNotes) {
searchRoot(it, testedNotes)
} else {
null
}
}?.filterNotNull()
val roots =
note.replyTo
?.map {
if (it !in testedNotes) {
searchRoot(it, testedNotes)
} else {
null
}
}
?.filterNotNull()
if (roots != null && roots.isNotEmpty()) {
return roots[0]
@@ -44,39 +73,39 @@ class ThreadAssembler {
fun findThreadFor(noteId: String): Set<Note> {
checkNotInMainThread()
val (result, elapsed) = measureTimedValue {
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet<Note>()
val (result, elapsed) =
measureTimedValue {
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet<Note>()
if (note.event != null) {
val thread = mutableSetOf<Note>()
if (note.event != null) {
val thread = mutableSetOf<Note>()
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)
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) }
thread.toSet()
} else {
setOf(note)
}
thread.toSet()
} else {
setOf(note)
}
}
println("Model Refresh: Thread loaded in $elapsed")
return result
}
fun loadDown(note: Note, thread: MutableSet<Note>) {
fun loadDown(
note: Note,
thread: MutableSet<Note>,
) {
if (note !in thread) {
thread.add(note)
note.replies.forEach {
loadDown(it, thread)
}
note.replies.forEach { loadDown(it, thread) }
}
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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.LruCache
@@ -16,7 +36,7 @@ object UrlCachedPreviewer {
suspend fun previewInfo(
url: String,
onReady: suspend (UrlPreviewState) -> Unit
onReady: suspend (UrlPreviewState) -> Unit,
) = withContext(Dispatchers.IO) {
cache[url]?.let {
onReady(it)
@@ -26,35 +46,39 @@ object UrlCachedPreviewer {
BahaUrlPreview(
url,
object : IUrlPreviewCallback {
override suspend fun onComplete(urlInfo: UrlInfoItem) = withContext(Dispatchers.IO) {
cache[url]?.let {
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
override suspend fun onComplete(urlInfo: UrlInfoItem) =
withContext(Dispatchers.IO) {
cache[url]?.let {
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
onReady(it)
return@withContext
}
}
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) =
withContext(Dispatchers.IO) {
cache[url]?.let {
onReady(it)
return@withContext
}
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
cache.put(url, state)
onReady(state)
}
val state = if (urlInfo.allFetchComplete() && urlInfo.url == url) {
UrlPreviewState.Loaded(urlInfo)
} else {
UrlPreviewState.Empty
}
cache.put(url, state)
onReady(state)
}
override suspend fun onFailed(throwable: Throwable) = withContext(Dispatchers.IO) {
cache[url]?.let {
onReady(it)
return@withContext
}
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
cache.put(url, state)
onReady(state)
}
}
).fetchUrlPreview()
},
)
.fetchUrlPreview()
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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.Immutable
@@ -25,6 +45,7 @@ import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import java.math.BigDecimal
@Stable
@@ -49,6 +70,7 @@ class User(val pubkeyHex: String) {
private set
fun pubkey() = Hex.decode(pubkeyHex)
fun pubkeyNpub() = pubkey().toNpub()
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
@@ -62,12 +84,13 @@ class User(val pubkeyHex: String) {
val names = fullName.split(' ')
val firstName = if (names[0].length <= 3) {
// too short. Remove Dr.
"${names[0]} ${names.getOrNull(1) ?: ""}"
} else {
names[0]
}
val firstName =
if (names[0].length <= 3) {
// too short. Remove Dr.
"${names[0]} ${names.getOrNull(1) ?: ""}"
} else {
names[0]
}
return firstName
}
@@ -81,7 +104,7 @@ class User(val pubkeyHex: String) {
}
fun bestDisplayName(): String? {
return info?.displayName?.ifBlank { null } ?: info?.display_name?.ifBlank { null }
return info?.displayName?.ifBlank { null }
}
fun nip05(): String? {
@@ -123,16 +146,18 @@ class User(val pubkeyHex: String) {
}
liveSet?.innerRelays?.invalidateData()
flowSet?.relays?.invalidateData()
}
fun addReport(note: Note) {
val author = note.author ?: return
if (author !in reports.keys) {
val reportsBy = reports[author]
if (reportsBy == null) {
reports = reports + Pair(author, setOf(note))
liveSet?.innerReports?.invalidateData()
} else if (reports[author]?.contains(note) == false) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
} else if (!reportsBy.contains(note)) {
reports = reports + Pair(author, reportsBy + note)
liveSet?.innerReports?.invalidateData()
}
}
@@ -140,7 +165,7 @@ class User(val pubkeyHex: String) {
fun removeReport(deleteNote: Note) {
val author = deleteNote.author ?: return
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
if (reports[author]?.contains(deleteNote) == true) {
reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote))
liveSet?.innerReports?.invalidateData()
@@ -148,61 +173,68 @@ class User(val pubkeyHex: String) {
}
}
fun addZap(zapRequest: Note, zap: Note?) {
if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.innerZaps?.invalidateData()
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
fun addZap(
zapRequest: Note,
zap: Note?,
) {
if (zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.innerZaps?.invalidateData()
}
}
fun removeZap(zapRequestOrZapEvent: Note) {
if (zapRequestOrZapEvent in zaps.keys) {
if (zaps.containsKey(zapRequestOrZapEvent)) {
zaps = zaps.minus(zapRequestOrZapEvent)
liveSet?.innerZaps?.invalidateData()
} else if (zapRequestOrZapEvent in zaps.values) {
} else if (zaps.containsValue(zapRequestOrZapEvent)) {
zaps = zaps.filter { it.value != zapRequestOrZapEvent }
liveSet?.innerZaps?.invalidateData()
}
}
fun zappedAmount(): BigDecimal {
return zaps.mapNotNull { it.value?.event }
.filterIsInstance<LnZapEvent>()
.mapNotNull {
it.amount
}.sumOf { it }
var amount = BigDecimal.ZERO
zaps.forEach {
val itemValue = (it.value?.event as? LnZapEvent)?.amount
if (itemValue != null) {
amount += itemValue
}
}
return amount
}
fun reportsBy(user: User): Set<Note> {
return reports[user] ?: emptySet()
}
fun reportAuthorsBy(users: Set<HexKey>): List<User> {
return reports.keys.filter { it.pubkeyHex in users }
}
fun countReportAuthorsBy(users: Set<HexKey>): Int {
return reports.keys.count { it.pubkeyHex in users }
return reports.count { it.key.pubkeyHex in users }
}
fun reportsBy(users: Set<HexKey>): List<Note> {
return reportAuthorsBy(users).mapNotNull {
reports[it]
}.flatten()
return reports
.mapNotNull {
if (it.key.pubkeyHex in users) {
it.value
} else {
null
}
}
.flatten()
}
@Synchronized
private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom {
checkNotInMainThread()
return privateChatrooms[key] ?: run {
val privateChatroom = Chatroom()
privateChatrooms = privateChatrooms + Pair(key, privateChatroom)
privateChatroom
}
return privateChatrooms[key]
?: run {
val privateChatroom = Chatroom()
privateChatrooms = privateChatrooms + Pair(key, privateChatroom)
privateChatroom
}
}
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
@@ -214,7 +246,10 @@ class User(val pubkeyHex: String) {
return privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key)
}
fun addMessage(room: ChatroomKey, msg: Note) {
fun addMessage(
room: ChatroomKey,
msg: Note,
) {
val privateChatroom = getOrCreatePrivateChatroom(room)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.addMessageSync(msg)
@@ -222,7 +257,10 @@ class User(val pubkeyHex: String) {
}
}
fun addMessage(user: User, msg: Note) {
fun addMessage(
user: User,
msg: Note,
) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.addMessageSync(msg)
@@ -234,7 +272,10 @@ class User(val pubkeyHex: String) {
getOrCreatePrivateChatroom(withKey)
}
fun removeMessage(user: User, msg: Note) {
fun removeMessage(
user: User,
msg: Note,
) {
checkNotInMainThread()
val privateChatroom = getOrCreatePrivateChatroom(user)
@@ -244,7 +285,10 @@ class User(val pubkeyHex: String) {
}
}
fun addRelayBeingUsed(relay: Relay, eventTime: Long) {
fun addRelayBeingUsed(
relay: Relay,
eventTime: Long,
) {
val here = relaysBeingUsed[relay.url]
if (here == null) {
relaysBeingUsed = relaysBeingUsed + Pair(relay.url, RelayInfo(relay.url, eventTime, 1))
@@ -258,7 +302,10 @@ class User(val pubkeyHex: String) {
liveSet?.innerRelayInfo?.invalidateData()
}
fun updateUserInfo(newUserInfo: UserMetadata, latestMetadata: MetadataEvent) {
fun updateUserInfo(
newUserInfo: UserMetadata,
latestMetadata: MetadataEvent,
) {
info = newUserInfo
info?.latestMetadata = latestMetadata
info?.updatedMetadataAt = latestMetadata.createdAt
@@ -286,25 +333,29 @@ class User(val pubkeyHex: String) {
fun isFollowingHashtagCached(tag: String): Boolean {
return latestContactList?.verifiedFollowTagSet?.let {
return tag.lowercase() in it
} ?: false
}
?: false
}
fun isFollowingGeohashCached(geoTag: String): Boolean {
return latestContactList?.verifiedFollowGeohashSet?.let {
return geoTag.lowercase() in it
} ?: false
}
?: false
}
fun isFollowingCached(user: User): Boolean {
return latestContactList?.verifiedFollowKeySet?.let {
return user.pubkeyHex in it
} ?: false
}
?: false
}
fun isFollowingCached(userHex: String): Boolean {
return latestContactList?.verifiedFollowKeySet?.let {
return userHex in it
} ?: false
}
?: false
}
fun transientFollowCount(): Int? {
@@ -312,7 +363,7 @@ class User(val pubkeyHex: String) {
}
suspend fun transientFollowerCount(): Int {
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
return LocalCache.users.count { it.value.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun cachedFollowingKeySet(): Set<HexKey> {
@@ -336,7 +387,7 @@ class User(val pubkeyHex: String) {
}
suspend fun cachedFollowerCount(): Int {
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
return LocalCache.users.count { it.value.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
@@ -345,9 +396,13 @@ class User(val pubkeyHex: String) {
return messagesToUser.roomMessages.any { this.pubkeyHex == it.author?.pubkeyHex }
}
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
return reports[loggedIn]?.firstOrNull() {
it.event is ReportEvent && (it.event as ReportEvent).reportedAuthor().any { it.reportType == type }
fun hasReport(
loggedIn: User,
type: ReportEvent.ReportType,
): Boolean {
return reports[loggedIn]?.firstOrNull {
it.event is ReportEvent &&
(it.event as ReportEvent).reportedAuthor().any { it.reportType == type }
} != null
}
@@ -356,20 +411,75 @@ class User(val pubkeyHex: String) {
}
var liveSet: UserLiveSet? = null
var flowSet: UserFlowSet? = null
fun live(): UserLiveSet {
if (liveSet == null) {
liveSet = UserLiveSet(this)
createOrDestroyLiveSync(true)
}
return liveSet!!
}
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
createOrDestroyLiveSync(false)
}
}
@Synchronized
fun createOrDestroyLiveSync(create: Boolean) {
if (create) {
if (liveSet == null) {
liveSet = UserLiveSet(this)
}
} else {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
}
}
}
@Synchronized
fun createOrDestroyFlowSync(create: Boolean) {
if (create) {
if (flowSet == null) {
flowSet = UserFlowSet(this)
}
} else {
if (flowSet != null && flowSet?.isInUse() == false) {
flowSet?.destroy()
flowSet = null
}
}
}
fun flow(): UserFlowSet {
if (flowSet == null) {
createOrDestroyFlowSync(true)
}
return flowSet!!
}
fun clearFlow() {
if (flowSet != null && flowSet?.isInUse() == false) {
createOrDestroyFlowSync(false)
}
}
}
@Stable
class UserFlowSet(u: User) {
// Observers line up here.
val relays = UserBundledRefresherFlow(u)
fun isInUse(): Boolean {
return relays.stateFlow.subscriptionCount.value > 0
}
fun destroy() {
relays.destroy()
}
}
@Stable
@@ -399,17 +509,11 @@ class UserLiveSet(u: User) {
val bookmarks = innerBookmarks.map { it }
val statuses = innerStatuses.map { it }
val profilePictureChanges = innerMetadata.map {
it.user.profilePicture()
}.distinctUntilChanged()
val profilePictureChanges = innerMetadata.map { it.user.profilePicture() }.distinctUntilChanged()
val nip05Changes = innerMetadata.map {
it.user.nip05()
}.distinctUntilChanged()
val nip05Changes = innerMetadata.map { it.user.nip05() }.distinctUntilChanged()
val userMetadataInfo = innerMetadata.map {
it.user.info
}.distinctUntilChanged()
val userMetadataInfo = innerMetadata.map { it.user.info }.distinctUntilChanged()
fun isInUse(): Boolean {
return metadata.hasObservers() ||
@@ -445,7 +549,7 @@ class UserLiveSet(u: User) {
data class RelayInfo(
val url: String,
var lastEvent: Long,
var counter: Long
var counter: Long,
)
class UserBundledRefresherLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
@@ -459,16 +563,14 @@ class UserBundledRefresherLiveData(val user: User) : LiveData<UserState>(UserSta
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate() {
bundler.invalidate {
checkNotInMainThread()
postValue(UserState(user))
}
}
fun <Y> map(
transform: (UserState) -> Y
): UserLoadingLiveData<Y> {
fun <Y> map(transform: (UserState) -> Y): UserLoadingLiveData<Y> {
val initialValue = this.value?.let { transform(it) }
val result = UserLoadingLiveData(user, initialValue)
result.addSource(this) { x -> result.value = transform(x) }
@@ -476,6 +578,27 @@ class UserBundledRefresherLiveData(val user: User) : LiveData<UserState>(UserSta
}
}
@Stable
class UserBundledRefresherFlow(val user: User) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
val stateFlow = MutableStateFlow(UserState(user))
fun destroy() {
bundler.cancel()
}
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate {
checkNotInMainThread()
stateFlow.emit(UserState(user))
}
}
}
class UserLoadingLiveData<Y>(val user: User, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
override fun onActive() {
super.onActive()
@@ -488,5 +611,4 @@ class UserLoadingLiveData<Y>(val user: User, initialValue: Y?) : MediatorLiveDat
}
}
@Immutable
class UserState(val user: User)
@Immutable class UserState(val user: User)

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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.graphics.Bitmap
@@ -7,26 +27,24 @@ import kotlin.math.pow
import kotlin.math.withSign
object BlurHashDecoder {
// cache Math.cos() calculations to improve performance.
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps
// the cache is enabled by default, it is recommended to disable it only when just a few images are displayed
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY *
// 2 * nBitmaps
// the cache is enabled by default, it is recommended to disable it only when just a few images
// are displayed
private val cacheCosinesX = HashMap<Int, DoubleArray>()
private val cacheCosinesY = HashMap<Int, DoubleArray>()
/**
* Clear calculations stored in memory cache.
* The cache is not big, but will increase when many image sizes are used,
* if the app needs memory it is recommended to clear it.
* Clear calculations stored in memory cache. The cache is not big, but will increase when many
* image sizes are used, if the app needs memory it is recommended to clear it.
*/
fun clearCache() {
cacheCosinesX.clear()
cacheCosinesY.clear()
}
/**
* Returns width/height
*/
/** Returns width/height */
fun aspectRatio(blurHash: String?): Float? {
if (blurHash == null || blurHash.length < 6) {
return null
@@ -45,10 +63,16 @@ object BlurHashDecoder {
* Decode a blur hash into a new bitmap.
*
* @param useCache use in memory cache for the calculated math, reused by images with same size.
* if the cache does not exist yet it will be created and populated with new calculations.
* By default it is true.
* if the cache does not exist yet it will be created and populated with new calculations. By
* default it is true.
*/
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
fun decode(
blurHash: String?,
width: Int,
height: Int,
punch: Float = 1f,
useCache: Boolean = true,
): Bitmap? {
checkNotInMainThread()
if (blurHash == null || blurHash.length < 6) {
@@ -62,20 +86,25 @@ object BlurHashDecoder {
}
val maxAcEnc = decode83(blurHash, 1, 2)
val maxAc = (maxAcEnc + 1) / 166f
val colors = Array(numCompX * numCompY) { i ->
if (i == 0) {
val colorEnc = decode83(blurHash, 2, 6)
decodeDc(colorEnc)
} else {
val from = 4 + i * 2
val colorEnc = decode83(blurHash, from, from + 2)
decodeAc(colorEnc, maxAc * punch)
val colors =
Array(numCompX * numCompY) { i ->
if (i == 0) {
val colorEnc = decode83(blurHash, 2, 6)
decodeDc(colorEnc)
} else {
val from = 4 + i * 2
val colorEnc = decode83(blurHash, from, from + 2)
decodeAc(colorEnc, maxAc * punch)
}
}
}
return composeBitmap(width, height, numCompX, numCompY, colors, useCache)
}
private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int {
private fun decode83(
str: String,
from: Int = 0,
to: Int = str.length,
): Int {
var result = 0
for (i in from until to) {
val index = charMap[str[i]] ?: -1
@@ -102,14 +131,17 @@ object BlurHashDecoder {
}
}
private fun decodeAc(value: Int, maxAc: Float): FloatArray {
private fun decodeAc(
value: Int,
maxAc: Float,
): FloatArray {
val r = value / (19 * 19)
val g = (value / 19) % 19
val b = value % 19
return floatArrayOf(
signedPow2((r - 9) / 9.0f) * maxAc,
signedPow2((g - 9) / 9.0f) * maxAc,
signedPow2((b - 9) / 9.0f) * maxAc
signedPow2((b - 9) / 9.0f) * maxAc,
)
}
@@ -121,7 +153,7 @@ object BlurHashDecoder {
numCompX: Int,
numCompY: Int,
colors: Array<FloatArray>,
useCache: Boolean
useCache: Boolean,
): Bitmap {
// use an array for better performance when writing pixel colors
val imageArray = IntArray(width * height)
@@ -151,22 +183,26 @@ object BlurHashDecoder {
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
}
private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when {
private fun getArrayForCosinesY(
calculate: Boolean,
height: Int,
numCompY: Int,
) = when {
calculate -> {
DoubleArray(height * numCompY).also {
cacheCosinesY[height * numCompY] = it
}
DoubleArray(height * numCompY).also { cacheCosinesY[height * numCompY] = it }
}
else -> {
cacheCosinesY[height * numCompY]!!
}
}
private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when {
private fun getArrayForCosinesX(
calculate: Boolean,
width: Int,
numCompX: Int,
) = when {
calculate -> {
DoubleArray(width * numCompX).also {
cacheCosinesX[width * numCompX] = it
}
DoubleArray(width * numCompX).also { cacheCosinesX[width * numCompX] = it }
}
else -> cacheCosinesX[width * numCompX]!!
}
@@ -176,7 +212,7 @@ object BlurHashDecoder {
x: Int,
numComp: Int,
y: Int,
size: Int
size: Int,
): Double {
if (calculate) {
this[x + numComp * y] = cos(Math.PI * y * x / size)
@@ -193,13 +229,92 @@ object BlurHashDecoder {
}
}
private val charMap = listOf(
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G',
'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',',
'-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~'
)
.mapIndexed { i, c -> c to i }
.toMap()
private val charMap =
listOf(
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z',
'#',
'$',
'%',
'*',
'+',
',',
'-',
'.',
':',
';',
'=',
'?',
'@',
'[',
']',
'^',
'_',
'{',
'|',
'}',
'~',
)
.mapIndexed { i, c -> c to i }
.toMap()
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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
@@ -18,9 +38,8 @@ import kotlin.math.roundToInt
@Stable
class BlurHashFetcher(
private val options: Options,
private val data: Uri
private val data: Uri,
) : Fetcher {
override suspend fun fetch(): FetchResult {
checkNotInMainThread()
@@ -31,11 +50,12 @@ class BlurHashFetcher(
val preferredWidth = 100
val bitmap = BlurHashDecoder.decode(
hash,
preferredWidth,
(preferredWidth * (1 / aspectRatio)).roundToInt()
)
val bitmap =
BlurHashDecoder.decode(
hash,
preferredWidth,
(preferredWidth * (1 / aspectRatio)).roundToInt(),
)
if (bitmap == null) {
throw Exception("Unable to convert Bluehash $hash")
@@ -44,23 +64,29 @@ class BlurHashFetcher(
return DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
isSampled = false,
dataSource = DataSource.MEMORY
dataSource = DataSource.MEMORY,
)
}
object Factory : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
override fun create(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher {
return BlurHashFetcher(options, data)
}
}
}
object BlurHashRequester {
fun imageRequest(context: Context, message: String): ImageRequest {
fun imageRequest(
context: Context,
message: String,
): ImageRequest {
val encodedMessage = URLEncoder.encode(message, "utf-8")
return ImageRequest
.Builder(context)
return ImageRequest.Builder(context)
.data("bluehash:$encodedMessage")
.fetcherFactory(BlurHashFetcher.Factory)
.build()

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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.util.Log
@@ -11,8 +31,12 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
import com.vitorpamplona.amethyst.ui.components.hashTagsPattern
import com.vitorpamplona.amethyst.ui.components.imageExtensions
import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionComparison
import com.vitorpamplona.amethyst.ui.components.tagIndex
import com.vitorpamplona.amethyst.ui.components.videoExtensions
import com.vitorpamplona.quartz.encoders.Nip54
import com.vitorpamplona.quartz.encoders.Nip92
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@@ -21,6 +45,8 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CancellationException
import java.util.regex.Pattern
@Immutable
@@ -29,7 +55,7 @@ data class RichTextViewerState(
val imagesForPager: ImmutableMap<String, ZoomableUrlContent>,
val imageList: ImmutableList<ZoomableUrlContent>,
val customEmoji: ImmutableMap<String, String>,
val paragraphs: ImmutableList<ParagraphState>
val paragraphs: ImmutableList<ParagraphState>,
)
data class ParagraphState(val words: ImmutableList<Segment>, val isRTL: Boolean)
@@ -37,7 +63,10 @@ data class ParagraphState(val words: ImmutableList<Segment>, val isRTL: Boolean)
object CachedRichTextParser {
val richTextCache = LruCache<String, RichTextViewerState>(200)
fun parseText(content: String, tags: ImmutableListOfLists<String>): RichTextViewerState {
fun parseText(
content: String,
tags: ImmutableListOfLists<String>,
): RichTextViewerState {
return if (richTextCache[content] != null) {
richTextCache[content]
} else {
@@ -49,51 +78,85 @@ object CachedRichTextParser {
}
// Group 1 = url, group 4 additional chars
// val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
// val noProtocolUrlValidator =
// Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
// Android9 seems to have an issue starting this regex.
val noProtocolUrlValidator = try {
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)")
} catch (e: Exception) {
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
}
val noProtocolUrlValidator =
try {
Pattern.compile(
"(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)",
)
} catch (e: Exception) {
Pattern.compile(
"(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)",
)
}
val HTTPRegex = "^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
val HTTPRegex =
"^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?"
.toRegex(RegexOption.IGNORE_CASE)
class RichTextParser() {
fun parseMediaUrl(
fullUrl: String,
eventTags: ImmutableListOfLists<String>,
): ZoomableUrlContent? {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip54().parse(fullUrl)
val tags = Nip92().parse(fullUrl, eventTags.lists)
ZoomableUrlImage(
url = fullUrl,
description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
contentWarning = frags["content-warning"] ?: tags["content-warning"],
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip54().parse(fullUrl)
val tags = Nip92().parse(fullUrl, eventTags.lists)
ZoomableUrlVideo(
url = fullUrl,
description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
contentWarning = frags["content-warning"] ?: tags["content-warning"],
)
} else {
null
}
}
fun parseText(
content: String,
tags: ImmutableListOfLists<String>
tags: ImmutableListOfLists<String>,
): RichTextViewerState {
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
val urlSet = urls.mapNotNullTo(LinkedHashSet(urls.size)) {
// removes e-mails
if (Patterns.EMAIL_ADDRESS.matcher(it.originalUrl).matches()) {
null
} else if (isNumber(it.originalUrl)) {
null
} else if (it.originalUrl.contains("")) {
null
} else {
if (HTTPRegex.matches(it.originalUrl)) {
it.originalUrl
} else {
val urlSet =
urls.mapNotNullTo(LinkedHashSet(urls.size)) {
// removes e-mails
if (Patterns.EMAIL_ADDRESS.matcher(it.originalUrl).matches()) {
null
} else if (isNumber(it.originalUrl)) {
null
} else if (it.originalUrl.contains("")) {
null
} else {
if (HTTPRegex.matches(it.originalUrl)) {
it.originalUrl
} else {
null
}
}
}
}
val imagesForPager = urlSet.mapNotNull { fullUrl ->
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableUrlImage(fullUrl)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableUrlVideo(fullUrl)
} else {
null
}
}.associateBy { it.url }
val imagesForPager =
urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl, tags) }.associateBy { it.url }
val imageList = imagesForPager.values.toList()
val emojiMap =
@@ -106,38 +169,45 @@ class RichTextParser() {
imagesForPager.toImmutableMap(),
imageList.toImmutableList(),
emojiMap.toImmutableMap(),
segments
segments,
)
}
private fun findTextSegments(content: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): ImmutableList<ParagraphState> {
var paragraphSegments = persistentListOf<ParagraphState>()
private fun findTextSegments(
content: String,
images: Set<String>,
urls: Set<String>,
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
): ImmutableList<ParagraphState> {
val lines = content.split('\n')
val paragraphSegments = ArrayList<ParagraphState>(lines.size)
content.split('\n').forEach { paragraph ->
var segments = persistentListOf<Segment>()
lines.forEach { paragraph ->
var isDirty = false
val isRTL = isArabic(paragraph)
val wordList = paragraph.trimEnd().split(' ')
val segments = ArrayList<Segment>(wordList.size)
wordList.forEach { word ->
val wordSegment = wordIdentifier(word, images, urls, emojis, tags)
if (wordSegment !is RegularTextSegment) {
isDirty = true
}
segments = segments.add(wordSegment)
segments.add(wordSegment)
}
val newSegments = if (isDirty) {
ParagraphState(segments, isRTL)
} else {
ParagraphState(persistentListOf<Segment>(RegularTextSegment(paragraph)), isRTL)
}
val newSegments =
if (isDirty) {
ParagraphState(segments.toPersistentList(), isRTL)
} else {
ParagraphState(persistentListOf<Segment>(RegularTextSegment(paragraph)), isRTL)
}
paragraphSegments = paragraphSegments.add(newSegments)
paragraphSegments.add(newSegments)
}
return paragraphSegments
return paragraphSegments.toImmutableList()
}
fun isNumber(word: String): Boolean {
@@ -152,7 +222,13 @@ class RichTextParser() {
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
}
private fun wordIdentifier(word: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): Segment {
private fun wordIdentifier(
word: String,
images: Set<String>,
urls: Set<String>,
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
): Segment {
val emailMatcher = Patterns.EMAIL_ADDRESS.matcher(word)
val phoneMatcher = Patterns.PHONE.matcher(word)
val schemelessMatcher = noProtocolUrlValidator.matcher(word)
@@ -182,7 +258,9 @@ class RichTextParser() {
} else if (word.contains(".") && schemelessMatcher.find()) {
val url = schemelessMatcher.group(1) // url
val additionalChars = schemelessMatcher.group(4) // additional chars
val pattern = """^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?""".toRegex(RegexOption.IGNORE_CASE)
val pattern =
"""^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?"""
.toRegex(RegexOption.IGNORE_CASE)
if (pattern.find(word) != null) {
SchemelessUrlSegment(word, url, additionalChars)
} else {
@@ -193,7 +271,10 @@ class RichTextParser() {
}
}
private fun parseHash(word: String, tags: ImmutableListOfLists<String>): Segment {
private fun parseHash(
word: String,
tags: ImmutableListOfLists<String>,
): Segment {
// First #[n]
val matcher = tagIndex.matcher(word)
@@ -215,6 +296,7 @@ class RichTextParser() {
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("Tag Parser", "Couldn't link tag $word", e)
}
@@ -229,6 +311,7 @@ class RichTextParser() {
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
}
@@ -242,53 +325,46 @@ class RichTextParser() {
}
}
@Immutable
open class Segment(val segmentText: String)
@Immutable open class Segment(val segmentText: String)
@Immutable class ImageSegment(segment: String) : Segment(segment)
@Immutable class LinkSegment(segment: String) : Segment(segment)
@Immutable class EmojiSegment(segment: String) : Segment(segment)
@Immutable class InvoiceSegment(segment: String) : Segment(segment)
@Immutable class WithdrawSegment(segment: String) : Segment(segment)
@Immutable class CashuSegment(segment: String) : Segment(segment)
@Immutable class EmailSegment(segment: String) : Segment(segment)
@Immutable class PhoneSegment(segment: String) : Segment(segment)
@Immutable class BechSegment(segment: String) : Segment(segment)
@Immutable
class ImageSegment(segment: String) : Segment(segment)
open class HashIndexSegment(segment: String, val hex: String, val extras: String?) :
Segment(segment)
@Immutable
class LinkSegment(segment: String) : Segment(segment)
class HashIndexUserSegment(segment: String, hex: String, extras: String?) :
HashIndexSegment(segment, hex, extras)
@Immutable
class EmojiSegment(segment: String) : Segment(segment)
@Immutable
class InvoiceSegment(segment: String) : Segment(segment)
@Immutable
class WithdrawSegment(segment: String) : Segment(segment)
@Immutable
class CashuSegment(segment: String) : Segment(segment)
@Immutable
class EmailSegment(segment: String) : Segment(segment)
@Immutable
class PhoneSegment(segment: String) : Segment(segment)
@Immutable
class BechSegment(segment: String) : Segment(segment)
@Immutable
open class HashIndexSegment(segment: String, val hex: String, val extras: String?) : Segment(segment)
@Immutable
class HashIndexUserSegment(segment: String, hex: String, extras: String?) : HashIndexSegment(segment, hex, extras)
@Immutable
class HashIndexEventSegment(segment: String, hex: String, extras: String?) : HashIndexSegment(segment, hex, extras)
class HashIndexEventSegment(segment: String, hex: String, extras: String?) :
HashIndexSegment(segment, hex, extras)
@Immutable
class HashTagSegment(segment: String, val hashtag: String, val extras: String?) : Segment(segment)
@Immutable
class SchemelessUrlSegment(segment: String, val url: String, val extras: String?) : Segment(segment)
class SchemelessUrlSegment(segment: String, val url: String, val extras: String?) :
Segment(segment)
@Immutable
class RegularTextSegment(segment: String) : Segment(segment)
@Immutable class RegularTextSegment(segment: String) : Segment(segment)
fun startsWithNIP19Scheme(word: String): Boolean {
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@")

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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
@@ -12,15 +32,14 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Base64
import kotlin.coroutines.cancellation.CancellationException
@Immutable
data class CashuToken(
val token: String,
val mint: String,
val totalAmount: Long,
val fees: Int,
val redeemInvoiceAmount: Long,
val proofs: JsonNode
val proofs: JsonNode,
)
class CashuProcessor {
@@ -38,37 +57,123 @@ class CashuProcessor {
for (proof in proofs) {
totalAmount += proof.get("amount").asLong()
}
val fees = Math.max(((totalAmount * 0.02).toInt()), 2)
val redeemInvoiceAmount = totalAmount - fees
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, fees, redeemInvoiceAmount, proofs))
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs))
} catch (e: Exception) {
if (e is CancellationException) throw e
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
}
}
suspend fun melt(token: CashuToken, lud16: String, onSuccess: (String, String) -> Unit, onError: (String, String) -> Unit, context: Context) {
suspend fun melt(
token: CashuToken,
lud16: String,
onSuccess: (String, String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
checkNotInMainThread()
runCatching {
LightningAddressResolver().lnAddressInvoice(
lnaddress = lud16,
milliSats = token.redeemInvoiceAmount * 1000, // Make invoice and leave room for fees
message = "Redeem Cashu",
onSuccess = { invoice ->
meltInvoice(token, invoice, onSuccess, onError, context)
},
onProgress = {
},
onError = onError,
context = context
LightningAddressResolver()
.lnAddressInvoice(
lnaddress = lud16,
// Make invoice and leave room for fees
milliSats = token.totalAmount * 1000,
message = "Calculate Fees for Cashu",
onSuccess = { baseInvoice ->
feeCalculator(
token.mint,
baseInvoice,
onSuccess = { fees ->
LightningAddressResolver()
.lnAddressInvoice(
lnaddress = lud16,
// Make invoice and leave room for fees
milliSats = (token.totalAmount - fees) * 1000,
message = "Redeem Cashu",
onSuccess = { invoice ->
meltInvoice(token, invoice, fees, onSuccess, onError, context)
},
onProgress = {},
onError = onError,
context = context,
)
},
onError = onError,
context,
)
},
onProgress = {},
onError = onError,
context = context,
)
}
}
fun feeCalculator(
mintAddress: String,
invoice: String,
onSuccess: (Int) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
checkNotInMainThread()
try {
val client = HttpClientManager.getHttpClient()
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
val factory = Event.mapper.nodeFactory
val jsonObject = factory.objectNode()
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request = Request.Builder().url(url).post(requestBody).build()
client.newCall(request).execute().use {
val body = it.body.string()
val tree = jacksonObjectMapper().readTree(body)
val feeCost = tree?.get("fee")?.asInt()
if (feeCost != null) {
onSuccess(
feeCost,
)
} else {
val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null }
onError(
context.getString(R.string.cashu_failed_redemption),
if (msg != null) {
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
context.getString(R.string.cashu_failed_redemption_explainer_error_msg)
},
)
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(
context.getString(R.string.cashu_successful_redemption),
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
}
}
private fun meltInvoice(token: CashuToken, invoice: String, onSuccess: (String, String) -> Unit, onError: (String, String) -> Unit, context: Context) {
private fun meltInvoice(
token: CashuToken,
invoice: String,
fees: Int,
onSuccess: (String, String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
try {
val client = HttpClient.getHttpClient()
val client = HttpClientManager.getHttpClient()
val url = token.mint + "/melt" // Melt cashu tokens at Mint
val factory = Event.mapper.nodeFactory
@@ -79,10 +184,7 @@ class CashuProcessor {
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request = Request.Builder()
.url(url)
.post(requestBody)
.build()
val request = Request.Builder().url(url).post(requestBody).build()
client.newCall(request).execute().use {
val body = it.body.string()
@@ -92,8 +194,12 @@ class CashuProcessor {
if (successful) {
onSuccess(
context.getString(R.string.cashu_sucessful_redemption),
context.getString(R.string.cashu_sucessful_redemption_explainer, token.totalAmount.toString(), token.fees.toString())
context.getString(R.string.cashu_successful_redemption),
context.getString(
R.string.cashu_successful_redemption_explainer,
token.totalAmount.toString(),
fees.toString(),
),
)
} else {
val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null }
@@ -103,12 +209,16 @@ class CashuProcessor {
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
context.getString(R.string.cashu_failed_redemption_explainer_error_msg)
}
},
)
}
}
} catch (e: Exception) {
onError(context.getString(R.string.cashu_sucessful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message))
if (e is CancellationException) throw e
onError(
context.getString(R.string.cashu_successful_redemption),
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
}
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.quartz.events.ImmutableListOfLists
@@ -8,8 +28,12 @@ fun String.isUTF16Char(pos: Int): Boolean {
fun String.firstFullCharOld(): String {
return when (this.length) {
0, 1 -> return this
2, 3 -> return if (isUTF16Char(0)) this.take(2) else this.take(1)
0,
1,
-> return this
2,
3,
-> return if (isUTF16Char(0)) this.take(2) else this.take(1)
else -> {
val first = isUTF16Char(0)
val second = isUTF16Char(2)
@@ -96,7 +120,7 @@ fun String.firstFullCharOrEmoji(tags: ImmutableListOfLists<String>): String {
val emojiParts = this.split(":", limit = 3)
if (emojiParts.size >= 2) {
val emojiName = emojiParts[1]
val emojiUrl = tags.lists.firstOrNull() { it.size > 1 && it[1] == emojiName }?.getOrNull(2)
val emojiUrl = tags.lists.firstOrNull { it.size > 1 && it[1] == emojiName }?.getOrNull(2)
if (emojiUrl != null) {
return ":$emojiName:$emojiUrl"
}

View File

@@ -1,311 +0,0 @@
package com.vitorpamplona.amethyst.service
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.util.LruCache
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
enum class SignerType {
SIGN_EVENT,
NIP04_ENCRYPT,
NIP04_DECRYPT,
NIP44_ENCRYPT,
NIP44_DECRYPT,
GET_PUBLIC_KEY,
DECRYPT_ZAP_EVENT
}
object ExternalSignerUtils {
val content = LruCache<String, String>(10)
var isActivityRunning: Boolean = false
val cachedDecryptedContent = mutableMapOf<HexKey, String>()
lateinit var account: Account
private lateinit var activityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var decryptResultLauncher: ActivityResultLauncher<Intent>
private lateinit var blockListResultLauncher: ActivityResultLauncher<Intent>
@OptIn(DelicateCoroutinesApi::class)
fun requestRejectedToast() {
GlobalScope.launch(Dispatchers.Main) {
Toast.makeText(
Amethyst.instance,
Amethyst.instance.getString(R.string.sign_request_rejected),
Toast.LENGTH_SHORT
).show()
}
}
@OptIn(DelicateCoroutinesApi::class)
fun default() {
isActivityRunning = false
ServiceManager.shouldPauseService = true
GlobalScope.launch(Dispatchers.IO) {
isActivityRunning = false
ServiceManager.shouldPauseService = true
}
}
@OptIn(DelicateCoroutinesApi::class)
fun start(activity: MainActivity) {
activityResultLauncher = activity.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode != Activity.RESULT_OK) {
requestRejectedToast()
} else {
val event = it.data?.getStringExtra("signature") ?: ""
val id = it.data?.getStringExtra("id") ?: ""
if (id.isNotBlank()) {
content.put(id, event)
}
}
default()
}
decryptResultLauncher = activity.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode != Activity.RESULT_OK) {
requestRejectedToast()
} else {
val event = it.data?.getStringExtra("signature") ?: ""
val id = it.data?.getStringExtra("id") ?: ""
if (id.isNotBlank()) {
content.put(id, event)
cachedDecryptedContent[id] = event
}
}
default()
}
blockListResultLauncher = activity.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode != Activity.RESULT_OK) {
requestRejectedToast()
} else {
val decryptedContent = it.data?.getStringExtra("signature") ?: ""
val id = it.data?.getStringExtra("id") ?: ""
if (id.isNotBlank()) {
cachedDecryptedContent[id] = decryptedContent
account.live.invalidateData()
}
}
default()
}
}
@OptIn(DelicateCoroutinesApi::class)
fun openSigner(
data: String,
type: SignerType,
intentResult: ActivityResultLauncher<Intent>,
pubKey: HexKey,
id: String
) {
try {
ServiceManager.shouldPauseService = false
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:$data"))
val signerType = when (type) {
SignerType.SIGN_EVENT -> "sign_event"
SignerType.NIP04_ENCRYPT -> "nip04_encrypt"
SignerType.NIP04_DECRYPT -> "nip04_decrypt"
SignerType.NIP44_ENCRYPT -> "nip44_encrypt"
SignerType.NIP44_DECRYPT -> "nip44_decrypt"
SignerType.GET_PUBLIC_KEY -> "get_public_key"
SignerType.DECRYPT_ZAP_EVENT -> "decrypt_zap_event"
}
intent.putExtra("type", signerType)
intent.putExtra("pubKey", pubKey)
intent.putExtra("id", id)
if (type !== SignerType.GET_PUBLIC_KEY) {
intent.putExtra("current_user", account.keyPair.pubKey.toNpub())
}
intent.`package` = "com.greenart7c3.nostrsigner"
intentResult.launch(intent)
} catch (e: Exception) {
Log.e("Signer", "Error opening Signer app", e)
GlobalScope.launch(Dispatchers.Main) {
Toast.makeText(
Amethyst.instance,
Amethyst.instance.getString(R.string.error_opening_external_signer),
Toast.LENGTH_SHORT
).show()
}
}
}
fun openSigner(event: EventInterface, columnName: String = "signature") {
checkNotInMainThread()
val result = getDataFromResolver(SignerType.SIGN_EVENT, arrayOf(event.toJson(), event.pubKey()), columnName)
if (result == null) {
ServiceManager.shouldPauseService = false
isActivityRunning = true
openSigner(
event.toJson(),
SignerType.SIGN_EVENT,
activityResultLauncher,
"",
event.id()
)
while (isActivityRunning) {
Thread.sleep(100)
}
} else {
content.put(event.id(), result)
}
}
fun decryptBlockList(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
if (result == null) {
isActivityRunning = true
openSigner(
encryptedContent,
signerType,
blockListResultLauncher,
pubKey,
id
)
} else {
content.put(id, result)
cachedDecryptedContent[id] = result
}
}
fun getDataFromResolver(signerType: SignerType, data: Array<out String>, columnName: String = "signature"): String? {
val localData = if (signerType !== SignerType.GET_PUBLIC_KEY) {
data.toList().plus(account.keyPair.pubKey.toNpub()).toTypedArray()
} else {
data
}
Amethyst.instance.contentResolver.query(
Uri.parse("content://com.greenart7c3.nostrsigner.$signerType"),
localData,
null,
null,
null
).use {
if (it == null) {
return null
}
if (it.moveToFirst()) {
val index = it.getColumnIndex(columnName)
if (index < 0) {
Log.d("getDataFromResolver", "column '$columnName' not found")
return null
}
return it.getString(index)
}
}
return null
}
fun decrypt(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
if (result == null) {
isActivityRunning = true
openSigner(
encryptedContent,
signerType,
decryptResultLauncher,
pubKey,
id
)
while (isActivityRunning) {
// do nothing
}
} else {
content.put(id, result)
cachedDecryptedContent[id] = result
}
}
fun decryptDM(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
if (result == null) {
openSigner(
encryptedContent,
signerType,
decryptResultLauncher,
pubKey,
id
)
} else {
content.put(id, result)
cachedDecryptedContent[id] = result
}
}
fun decryptBookmark(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
if (result == null) {
openSigner(
encryptedContent,
signerType,
decryptResultLauncher,
pubKey,
id
)
} else {
content.put(id, result)
cachedDecryptedContent[id] = result
}
}
fun encrypt(decryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_ENCRYPT) {
content.remove(id)
cachedDecryptedContent.remove(id)
val result = getDataFromResolver(signerType, arrayOf(decryptedContent, pubKey))
if (result == null) {
isActivityRunning = true
openSigner(
decryptedContent,
signerType,
activityResultLauncher,
pubKey,
id
)
while (isActivityRunning) {
Thread.sleep(100)
}
} else {
content.put(id, result)
}
}
fun decryptZapEvent(event: LnZapRequestEvent) {
val result = getDataFromResolver(SignerType.DECRYPT_ZAP_EVENT, arrayOf(event.toJson(), event.pubKey))
if (result == null) {
openSigner(
event.toJson(),
SignerType.DECRYPT_ZAP_EVENT,
decryptResultLauncher,
event.pubKey,
event.id
)
} else {
content.put(event.id, result)
cachedDecryptedContent[event.id] = result
}
}
}

View File

@@ -1,89 +1,268 @@
/**
* Copyright (c) 2023 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.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.media.MediaDataSource
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.quartz.crypto.CryptoUtils
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 url: String,
val mimeType: String?,
val hash: String,
val size: Int,
val dim: String?,
val blurHash: String?,
val alt: String? = null,
val sensitiveContent: Boolean = false
) {
companion object {
suspend fun prepare(fileUrl: String, mimeType: String?, alt: String?, sensitiveContent: Boolean, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
suspend fun prepare(
fileUrl: String,
mimeType: String?,
dimPrecomputed: String?,
onReady: (FileHeader) -> Unit,
onError: (String?) -> Unit,
) {
try {
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
if (imageData != null) {
prepare(imageData, fileUrl, mimeType, alt, sensitiveContent, onReady, onError)
prepare(imageData, mimeType, dimPrecomputed, onReady, onError)
} else {
onError()
onError(null)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
onError()
onError(e.message)
}
}
fun prepare(
data: ByteArray,
fileUrl: String,
mimeType: String?,
alt: String?,
sensitiveContent: Boolean,
dimPrecomputed: String?,
onReady: (FileHeader) -> Unit,
onError: () -> Unit
onError: (String?) -> Unit,
) {
try {
val hash = CryptoUtils.sha256(data).toHexKey()
val size = data.size
val (blurHash, dim) = if (mimeType?.startsWith("image/") == true) {
val opt = BitmapFactory.Options()
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
val (blurHash, dim) =
if (mimeType?.startsWith("image/") == true) {
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 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 dim = "${mBitmap.width}x${mBitmap.height}"
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
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)
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)
}
} else if (mimeType?.startsWith("video/") == true) {
val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data))
val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed
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") {
Pair(blurhash, newDim)
} else {
Pair(blurhash, null)
}
} else {
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
Pair(null, null)
}
} else {
Pair(null, null)
}
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, alt, sensitiveContent))
onReady(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()
onError(e.message)
}
}
}
}
fun MediaMetadataRetriever.getThumbnail(): Bitmap? {
val raw: ByteArray? = getEmbeddedPicture()
if (raw != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
return ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw))
}
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val params = BitmapParams()
params.preferredConfig = Bitmap.Config.ARGB_8888
// Fall back to middle of video
// Note: METADATA_KEY_DURATION unit is in ms, not us.
val thumbnailTimeUs: Long =
(extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0) * 1000 / 2
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
getFrameAtTime(thumbnailTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, params)
} else {
null
}
} else {
null
}
}
fun MediaMetadataRetriever.prepareDimFromVideo(): String? {
val width = prepareVideoWidth() ?: return null
val height = prepareVideoHeight() ?: return null
return "${width}x$height"
}
fun MediaMetadataRetriever.prepareVideoWidth(): Int? {
val widthData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
return if (widthData.isNullOrEmpty()) {
null
} else {
widthData.toInt()
}
}
fun MediaMetadataRetriever.prepareVideoHeight(): Int? {
val heightData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
return if (heightData.isNullOrEmpty()) {
null
} else {
heightData.toInt()
}
}
class ByteArrayMediaDataSource(var imageData: ByteArray) : MediaDataSource() {
override fun getSize(): Long {
return imageData.size.toLong()
}
@Throws(IOException::class)
override fun readAt(
position: Long,
buffer: ByteArray,
offset: Int,
size: Int,
): Int {
if (position >= imageData.size) {
return -1
}
val newSize =
if (position + size > imageData.size) {
size - ((position.toInt() + size) - imageData.size)
} else {
size
}
imageData.copyInto(buffer, offset, position.toInt(), position.toInt() + newSize)
return newSize
}
@Throws(IOException::class)
override fun close() {}
}

View File

@@ -1,44 +0,0 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
import java.time.Duration
import kotlin.properties.Delegates
object HttpClient {
var proxyChangeListeners = ArrayList<() -> Unit>()
// 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()
}
}
}
fun start(account: Account?) {
this.internalProxy = account?.proxy
}
fun getHttpClient(): OkHttpClient {
val seconds = if (internalProxy != null) 20L else 10L
val duration = Duration.ofSeconds(seconds)
return OkHttpClient.Builder()
.proxy(internalProxy)
.readTimeout(duration)
.connectTimeout(duration)
.writeTimeout(duration)
.build()
}
fun getProxy(): Proxy? {
return internalProxy
}
fun initProxy(useProxy: Boolean, hostname: String, port: Int): Proxy? {
return if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) else null
}
}

View File

@@ -0,0 +1,115 @@
/**
* Copyright (c) 2023 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.util.Log
import com.vitorpamplona.amethyst.BuildConfig
import okhttp3.Interceptor
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 {
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
// 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() }
}
}
fun setDefaultProxy(proxy: Proxy?) {
if (internalProxy != proxy) {
Log.d("HttpClient", "Changing proxy to: ${proxy != null}")
this.internalProxy = proxy
// recreates singleton
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
}
}
fun setDefaultTimeout(timeout: Duration) {
Log.d("HttpClient", "Changing timeout to: $timeout")
if (this.defaultTimeout.seconds != timeout.seconds) {
this.defaultTimeout = timeout
// recreates singleton
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
}
}
private fun buildHttpClient(
proxy: Proxy?,
timeout: Duration,
): OkHttpClient {
val seconds = if (proxy != null) timeout.seconds * 2 else timeout.seconds
val duration = Duration.ofSeconds(seconds)
return OkHttpClient.Builder()
.proxy(proxy)
.readTimeout(duration)
.connectTimeout(duration)
.writeTimeout(duration)
.addInterceptor(DefaultContentTypeInterceptor())
.followRedirects(true)
.followSslRedirects(true)
.build()
}
class DefaultContentTypeInterceptor : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest: Request = chain.request()
val requestWithUserAgent: Request =
originalRequest
.newBuilder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.build()
return chain.proceed(requestWithUserAgent)
}
}
fun getHttpClient(): OkHttpClient {
if (this.defaultHttpClient == null) {
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
}
return defaultHttpClient!!
}
fun initProxy(
useProxy: Boolean,
hostname: String,
port: Int,
): Proxy? {
return if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) else null
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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
@@ -9,6 +29,7 @@ import android.location.LocationManager
import android.os.HandlerThread
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
class LocationUtil(context: Context) {
@@ -17,7 +38,8 @@ class LocationUtil(context: Context) {
const val MIN_DISTANCE: Float = 0.0f
}
private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private val locationManager =
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private var locationListener: LocationListener? = null
val locationStateFlow = MutableStateFlow<Location>(Location(LocationManager.NETWORK_PROVIDER))
@@ -31,47 +53,60 @@ class LocationUtil(context: Context) {
}
@SuppressLint("MissingPermission")
fun start(minTimeMs: Long = MIN_TIME, minDistanceM: Float = MIN_DISTANCE) {
fun start(
minTimeMs: Long = MIN_TIME,
minDistanceM: Float = MIN_DISTANCE,
) {
locationListener().let {
locationListener = it
locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, minTimeMs, minDistanceM, it, locHandlerThread.looper)
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)
}
locationListener?.let { locationManager.removeUpdates(it) }
isStart.value = false
}
private fun locationListener() = object : LocationListener {
override fun onLocationChanged(location: Location) {
locationStateFlow.value = location
}
private fun locationListener() =
object : LocationListener {
override fun onLocationChanged(location: Location) {
locationStateFlow.value = location
}
override fun onProviderEnabled(provider: String) {
providerState.value = true
}
override fun onProviderEnabled(provider: String) {
providerState.value = true
}
override fun onProviderDisabled(provider: String) {
providerState.value = false
override fun onProviderDisabled(provider: String) {
providerState.value = false
}
}
}
}
class ReverseGeoLocationUtil {
suspend fun execute(
location: Location,
context: Context
context: Context,
): String? {
return try {
Geocoder(context).getFromLocation(location.latitude, location.longitude, 1)?.firstOrNull()?.let { address ->
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode).joinToString(", ")
}
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

@@ -1,10 +1,32 @@
/**
* Copyright (c) 2023 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.os.Looper
import com.vitorpamplona.amethyst.BuildConfig
fun checkNotInMainThread() {
if (BuildConfig.DEBUG && isMainThread()) throw OnMainThreadException("It should not be in the MainThread")
if (BuildConfig.DEBUG && isMainThread()) {
throw OnMainThreadException("It should not be in the MainThread")
}
}
fun isMainThread() = Looper.myLooper() == Looper.getMainLooper()

View File

@@ -1,34 +0,0 @@
package com.vitorpamplona.amethyst.service
import androidx.compose.runtime.Immutable
import java.util.regex.Pattern
@Immutable
class NIP30Parser {
val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
fun buildArray(input: String): List<String> {
val matcher = customEmojiPattern.matcher(input)
val list = mutableListOf<String>()
while (matcher.find()) {
list.add(matcher.group())
}
if (list.isEmpty()) {
return listOf(input)
}
val regularChars = input.split(customEmojiPattern.toRegex())
val finalList = mutableListOf<String>()
var index = 0
for (e in regularChars) {
finalList.add(e)
if (index < list.size) {
finalList.add(list[index])
}
index++
}
return finalList
}
}

View File

@@ -0,0 +1,152 @@
/**
* Copyright (c) 2023 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 com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
class Nip05NostrAddressVerifier() {
fun assembleUrl(nip05address: String): String? {
val parts = nip05address.trim().split("@")
if (parts.size == 2) {
return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}"
}
if (parts.size == 1) {
return "https://${parts[0]}/.well-known/nostr.json?name=_"
}
return null
}
suspend fun fetchNip05Json(
nip05: String,
onSuccess: (String) -> Unit,
onError: (String) -> Unit,
) = withContext(Dispatchers.IO) {
checkNotInMainThread()
val url = assembleUrl(nip05)
if (url == null) {
onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
return@withContext
}
try {
val request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
HttpClientManager.getHttpClient()
.newCall(request)
.enqueue(
object : Callback {
override fun onResponse(
call: Call,
response: Response,
) {
checkNotInMainThread()
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError(
"Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct",
)
}
}
}
override fun onFailure(
call: Call,
e: java.io.IOException,
) {
onError(
"Could not resolve $url. Check if the server is up and if the address $nip05 is correct",
)
e.printStackTrace()
}
},
)
} catch (e: Exception) {
if (e is CancellationException) throw e
onError("Could not resolve '$url': ${e.message}")
}
}
suspend fun verifyNip05(
nip05: String,
onSuccess: (String) -> Unit,
onError: (String) -> Unit,
) {
// check fails on tests
checkNotInMainThread()
val mapper = jacksonObjectMapper()
fetchNip05Json(
nip05,
onSuccess = {
checkNotInMainThread()
// NIP05 usernames are case insensitive, but JSON properties are not
// converts the json to lowercase and then tries to access the username via a
// lowercase version of the username.
val nip05url =
try {
mapper.readTree(it.lowercase())
} catch (e: Throwable) {
if (e is CancellationException) throw e
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
null
}
val parts = nip05.split("@")
val user =
if (parts.size == 2) {
parts[0].lowercase()
} else {
"_"
}
val hexKey = nip05url?.get("names")?.get(user)?.asText()
if (hexKey == null) {
onError("Username not found in the NIP05 JSON")
} else {
onSuccess(hexKey)
}
},
onError = onError,
)
}
}

View File

@@ -1,104 +0,0 @@
package com.vitorpamplona.amethyst.service
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
class Nip05Verifier() {
fun assembleUrl(nip05address: String): String? {
val parts = nip05address.trim().split("@")
if (parts.size == 2) {
return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}"
}
if (parts.size == 1) {
return "https://${parts[0]}/.well-known/nostr.json?name=_"
}
return null
}
suspend fun fetchNip05Json(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
checkNotInMainThread()
val url = assembleUrl(nip05)
if (url == null) {
onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
return@withContext
}
try {
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
checkNotInMainThread()
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct")
}
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve $url. Check if the server is up and if the address $nip05 is correct")
e.printStackTrace()
}
})
} catch (e: java.lang.Exception) {
onError("Could not resolve '$url': ${e.message}")
}
}
suspend fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
// check fails on tests
checkNotInMainThread()
val mapper = jacksonObjectMapper()
fetchNip05Json(
nip05,
onSuccess = {
checkNotInMainThread()
// NIP05 usernames are case insensitive, but JSON properties are not
// converts the json to lowercase and then tries to access the username via a
// lowercase version of the username.
val nip05url = try {
mapper.readTree(it.lowercase())
} catch (t: Throwable) {
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
null
}
val parts = nip05.split("@")
val user = if (parts.size == 2) {
parts[0].lowercase()
} else {
"_"
}
val hexKey = nip05url?.get("names")?.get(user)?.asText()
if (hexKey == null) {
onError("Username not found in the NIP05 JSON")
} else {
onSuccess(hexKey)
}
},
onError = onError
)
}
}

View File

@@ -0,0 +1,132 @@
/**
* Copyright (c) 2023 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.util.Log
import android.util.LruCache
import com.vitorpamplona.amethyst.model.RelayInformation
import kotlinx.coroutines.CancellationException
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
object Nip11CachedRetriever {
val relayInformationDocumentCache = LruCache<String, RelayInformation>(100)
val retriever = Nip11Retriever()
suspend fun loadRelayInfo(
dirtyUrl: String,
onInfo: (RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
) {
val url = retriever.cleanUrl(dirtyUrl)
val doc = relayInformationDocumentCache.get(url)
if (doc != null) {
onInfo(doc)
} else {
Nip11Retriever()
.loadRelayInfo(
url,
dirtyUrl,
onInfo = {
relayInformationDocumentCache.put(url, it)
onInfo(it)
},
onError,
)
}
}
}
class Nip11Retriever {
enum class ErrorCode {
FAIL_TO_ASSEMBLE_URL,
FAIL_TO_REACH_SERVER,
FAIL_TO_PARSE_RESULT,
FAIL_WITH_HTTP_STATUS,
}
fun cleanUrl(dirtyUrl: String): String {
return if (dirtyUrl.contains("://")) {
dirtyUrl.replace("wss://", "https://").replace("ws://", "http://")
} else {
"https://$dirtyUrl"
}
}
suspend fun loadRelayInfo(
url: String,
dirtyUrl: String,
onInfo: (RelayInformation) -> Unit,
onError: (String, ErrorCode, String?) -> Unit,
) {
try {
val request: Request =
Request.Builder().header("Accept", "application/nostr+json").url(url).build()
HttpClientManager.getHttpClient()
.newCall(request)
.enqueue(
object : Callback {
override fun onResponse(
call: Call,
response: Response,
) {
checkNotInMainThread()
response.use {
val body = it.body.string()
try {
if (it.isSuccessful) {
onInfo(RelayInformation.fromJson(body))
} else {
onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString())
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"RelayInfoFail",
"Resulting Message from Relay $dirtyUrl in not parseable: $body",
e,
)
onError(dirtyUrl, ErrorCode.FAIL_TO_PARSE_RESULT, e.message)
}
}
}
override fun onFailure(
call: Call,
e: IOException,
) {
Log.e("RelayInfoFail", "$dirtyUrl unavailable", e)
onError(dirtyUrl, ErrorCode.FAIL_TO_REACH_SERVER, e.message)
}
},
)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e)
onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
}
}
}

View File

@@ -1,103 +0,0 @@
package com.vitorpamplona.amethyst.service
import android.util.Log
import android.util.LruCache
import com.vitorpamplona.amethyst.model.RelayInformation
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
object Nip11CachedRetriever {
val relayInformationDocumentCache = LruCache<String, RelayInformation>(100)
val retriever = Nip11Retriever()
suspend fun loadRelayInfo(
dirtyUrl: String,
onInfo: (RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit
) {
val url = retriever.cleanUrl(dirtyUrl)
val doc = relayInformationDocumentCache.get(url)
if (doc != null) {
onInfo(doc)
} else {
Nip11Retriever().loadRelayInfo(
url,
dirtyUrl,
onInfo = {
relayInformationDocumentCache.put(url, it)
onInfo(it)
},
onError
)
}
}
}
class Nip11Retriever {
enum class ErrorCode {
FAIL_TO_ASSEMBLE_URL,
FAIL_TO_REACH_SERVER,
FAIL_TO_PARSE_RESULT,
FAIL_WITH_HTTP_STATUS
}
fun cleanUrl(dirtyUrl: String): String {
return if (dirtyUrl.contains("://")) {
dirtyUrl
.replace("wss://", "https://")
.replace("ws://", "http://")
} else {
"https://$dirtyUrl"
}
}
suspend fun loadRelayInfo(
url: String,
dirtyUrl: String,
onInfo: (RelayInformation) -> Unit,
onError: (String, ErrorCode, String?) -> Unit
) {
try {
val request: Request = Request
.Builder()
.header("Accept", "application/nostr+json")
.url(url)
.build()
HttpClient.getHttpClient()
.newCall(request)
.enqueue(
object : Callback {
override fun onResponse(call: Call, response: Response) {
checkNotInMainThread()
response.use {
val body = it.body.string()
try {
if (it.isSuccessful) {
onInfo(RelayInformation.fromJson(body))
} else {
onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString())
}
} catch (e: Exception) {
Log.e("RelayInfoFail", "Resulting Message from Relay $dirtyUrl in not parseable: $body", e)
onError(dirtyUrl, ErrorCode.FAIL_TO_PARSE_RESULT, e.message)
}
}
}
override fun onFailure(call: Call, e: IOException) {
Log.e("RelayInfoFail", "$dirtyUrl unavailable", e)
onError(dirtyUrl, ErrorCode.FAIL_TO_REACH_SERVER, e.message)
}
}
)
} catch (e: Exception) {
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e)
onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
}
}
}

View File

@@ -0,0 +1,55 @@
/**
* Copyright (c) 2023 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 androidx.compose.runtime.Immutable
import java.util.regex.Pattern
@Immutable
class Nip30CustomEmoji {
val customEmojiPattern: Pattern =
Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
fun buildArray(input: String): List<String> {
val matcher = customEmojiPattern.matcher(input)
val list = mutableListOf<String>()
while (matcher.find()) {
list.add(matcher.group())
}
if (list.isEmpty()) {
return listOf(input)
}
val regularChars = input.split(customEmojiPattern.toRegex())
val finalList = mutableListOf<String>()
var index = 0
for (e in regularChars) {
finalList.add(e)
if (index < list.size) {
finalList.add(list[index])
}
index++
}
return finalList
}
}

View File

@@ -1,32 +0,0 @@
package com.vitorpamplona.amethyst.ui.note
import android.net.Uri
import com.vitorpamplona.amethyst.model.Nip47URI
import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
// Rename to the corect nip number when ready.
object Nip47 {
fun parse(uri: String): Nip47URI {
// nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D
val url = Uri.parse(uri)
if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") {
throw IllegalArgumentException("Not a Wallet Connect QR Code")
}
val pubkey = url.host ?: throw IllegalArgumentException("Hostname cannot be null")
val pubkeyHex = try {
decodePublicKey(pubkey).toHexKey()
} catch (e: Exception) {
throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey")
}
val relay = url.getQueryParameter("relay")
val secret = url.getQueryParameter("secret")
return Nip47URI(pubkeyHex, relay, secret)
}
}

View File

@@ -0,0 +1,55 @@
/**
* Copyright (c) 2023 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note
import android.net.Uri
import com.vitorpamplona.amethyst.model.Nip47URI
import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
import kotlinx.coroutines.CancellationException
// Rename to the corect nip number when ready.
object Nip47WalletConnectParser {
fun parse(uri: String): Nip47URI {
// nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D
val url = Uri.parse(uri)
if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") {
throw IllegalArgumentException("Not a Wallet Connect QR Code")
}
val pubkey = url.host ?: throw IllegalArgumentException("Hostname cannot be null")
val pubkeyHex =
try {
decodePublicKey(pubkey).toHexKey()
} catch (e: Exception) {
if (e is CancellationException) throw e
throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey")
}
val relay = url.getQueryParameter("relay")
val secret = url.getQueryParameter("secret")
return Nip47URI(pubkeyHex, relay, secret)
}
}

View File

@@ -0,0 +1,115 @@
/**
* Copyright (c) 2023 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.util.Log
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kotlinx.coroutines.CancellationException
import okhttp3.Request
object Nip96MediaServers {
val DEFAULT =
listOf(
ServerName("Nostr.Build", "https://nostr.build"),
ServerName("NostrCheck.me", "https://nostrcheck.me"),
ServerName("Nostrage", "https://nostrage.com"),
ServerName("Sove", "https://sove.rent"),
ServerName("Sovbit", "https://files.sovbit.host"),
ServerName("Void.cat", "https://void.cat"),
)
data class ServerName(val name: String, val baseUrl: String)
val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf()
suspend fun load(url: String): Nip96Retriever.ServerInfo {
val cached = cache[url]
if (cached != null) return cached
val fetched = Nip96Retriever().loadInfo(url)
cache[url] = fetched
return fetched
}
}
class Nip96Retriever {
data class ServerInfo(
@JsonProperty("api_url") val apiUrl: String,
@JsonProperty("download_url") val downloadUrl: String? = null,
@JsonProperty("delegated_to_url") val delegatedToUrl: String? = null,
@JsonProperty("supported_nips") val supportedNips: ArrayList<Int> = arrayListOf(),
@JsonProperty("tos_url") val tosUrl: String? = null,
@JsonProperty("content_types") val contentTypes: ArrayList<MimeType> = arrayListOf(),
@JsonProperty("plans") val plans: Map<PlanName, Plan> = mapOf(),
)
data class Plan(
@JsonProperty("name") val name: String? = null,
@JsonProperty("is_nip98_required") val isNip98Required: Boolean? = null,
@JsonProperty("url") val url: String? = null,
@JsonProperty("max_byte_size") val maxByteSize: Long? = null,
@JsonProperty("file_expiration") val fileExpiration: ArrayList<Int> = arrayListOf(),
@JsonProperty("media_transformations")
val mediaTransformations: Map<MimeType, Array<String>> = emptyMap(),
)
fun parse(body: String): ServerInfo {
val mapper =
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, ServerInfo::class.java)
}
suspend fun loadInfo(baseUrl: String): ServerInfo {
checkNotInMainThread()
val request: Request =
Request.Builder()
.header("Accept", "application/nostr+json")
.url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json")
.build()
HttpClientManager.getHttpClient().newCall(request).execute().use { response ->
checkNotInMainThread()
response.use {
val body = it.body.string()
try {
if (it.isSuccessful) {
return parse(body)
} else {
throw RuntimeException(
"Resulting Message from $baseUrl is an error: ${response.code} ${response.message}",
)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e)
throw e
}
}
}
}
}
typealias PlanName = String
typealias MimeType = String

View File

@@ -0,0 +1,317 @@
/**
* Copyright (c) 2023 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.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.core.net.toFile
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.Account
import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.RequestBody
import okio.BufferedSink
import okio.source
import java.io.InputStream
import java.util.Base64
import kotlin.coroutines.resume
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
fun randomChars() = List(16) { charPool.random() }.joinToString("")
class Nip96Uploader(val account: Account?) {
suspend fun uploadImage(
uri: Uri,
contentType: String?,
size: Long?,
alt: String?,
sensitiveContent: String?,
server: Nip96MediaServers.ServerName,
contentResolver: ContentResolver,
onProgress: (percentage: Float) -> Unit,
): PartialEvent {
val serverInfo =
Nip96Retriever()
.loadInfo(
server.baseUrl,
)
return uploadImage(
uri,
contentType,
size,
alt,
sensitiveContent,
serverInfo,
contentResolver,
onProgress,
)
}
suspend fun uploadImage(
uri: Uri,
contentType: String?,
size: Long?,
alt: String?,
sensitiveContent: String?,
server: Nip96Retriever.ServerInfo,
contentResolver: ContentResolver,
onProgress: (percentage: Float) -> Unit,
): PartialEvent {
checkNotInMainThread()
val myContentType = contentType ?: contentResolver.getType(uri)
val imageInputStream = contentResolver.openInputStream(uri)
val length =
size
?: contentResolver.query(uri, null, null, null, null)?.use {
it.moveToFirst()
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.getLong(sizeIndex)
}
?: kotlin.runCatching { uri.toFile().length() }.getOrNull() ?: 0
checkNotNull(imageInputStream) { "Can't open the image input stream" }
return uploadImage(
imageInputStream,
length,
myContentType,
alt,
sensitiveContent,
server,
onProgress,
)
}
suspend fun uploadImage(
inputStream: InputStream,
length: Long,
contentType: String?,
alt: String?,
sensitiveContent: String?,
server: Nip96Retriever.ServerInfo,
onProgress: (percentage: Float) -> Unit,
): PartialEvent {
checkNotInMainThread()
val fileName = randomChars()
val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClientManager.getHttpClient()
val requestBody: RequestBody
val requestBuilder = Request.Builder()
requestBody =
MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("expiration", "")
.addFormDataPart("size", length.toString())
.also { body ->
alt?.let { body.addFormDataPart("alt", it) }
sensitiveContent?.let { body.addFormDataPart("content-warning", it) }
contentType?.let { body.addFormDataPart("content_type", it) }
}
.addFormDataPart(
"file",
"$fileName.$extension",
object : RequestBody() {
override fun contentType() = contentType?.toMediaType()
override fun contentLength() = length
override fun writeTo(sink: BufferedSink) {
inputStream.source().use(sink::writeAll)
}
},
)
.build()
nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) }
requestBuilder
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(server.apiUrl)
.post(requestBody)
val request = requestBuilder.build()
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
response.body.use { body ->
val str = body.string()
val result = parseResults(str)
if (!result.processingUrl.isNullOrBlank()) {
return waitProcessing(result, server, onProgress)
} else if (result.status == "success" && result.nip94Event != null) {
return result.nip94Event
} else {
throw RuntimeException("Failed to upload with message: ${result.message}")
}
}
} else {
throw RuntimeException("Error Uploading image: ${response.code}")
}
}
}
suspend fun delete(
hash: String,
contentType: String?,
server: Nip96Retriever.ServerInfo,
): Boolean {
val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClientManager.getHttpClient()
val requestBuilder = Request.Builder()
nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) }
println(server.apiUrl.removeSuffix("/") + "/$hash.$extension")
val request =
requestBuilder
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(server.apiUrl.removeSuffix("/") + "/$hash.$extension")
.delete()
.build()
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
response.body.use { body ->
val str = body.string()
val result = parseDeleteResults(str)
return result.status == "success"
}
} else {
throw RuntimeException("Error Uploading image: ${response.code}")
}
}
}
private suspend fun waitProcessing(
result: Nip96Result,
server: Nip96Retriever.ServerInfo,
onProgress: (percentage: Float) -> Unit,
): PartialEvent {
val client = HttpClientManager.getHttpClient()
var currentResult = result
while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) {
onProgress((currentResult.percentage ?: 100) / 100f)
val request: Request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(result.processingUrl)
.build()
client.newCall(request).execute().use {
if (it.isSuccessful) {
it.body.use { currentResult = parseResults(it.string()) }
}
}
delay(500)
}
onProgress((currentResult.percentage ?: 100) / 100f)
val nip94 = currentResult.nip94Event
if (nip94 != null) {
return nip94
} else {
throw RuntimeException("Error waiting for processing. Final result is unavailable")
}
}
suspend fun nip98Header(url: String): String? {
return withTimeoutOrNull(5000) {
suspendCancellableCoroutine { continuation ->
nip98Header(url, "POST") { authorizationToken -> continuation.resume(authorizationToken) }
}
}
}
fun nip98Header(
url: String,
method: String,
file: ByteArray? = null,
onReady: (String?) -> Unit,
) {
val myAccount = account
if (myAccount == null) {
onReady(null)
return
}
myAccount.createHTTPAuthorization(url, method, file) {
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
onReady("Nostr $encodedNIP98Event")
}
}
data class DeleteResult(
val status: String?,
val message: String?,
)
private fun parseDeleteResults(body: String): DeleteResult {
val mapper =
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, DeleteResult::class.java)
}
data class Nip96Result(
val status: String? = null,
val message: String? = null,
@JsonProperty("processing_url") val processingUrl: String? = null,
val percentage: Int? = null,
@JsonProperty("nip94_event") val nip94Event: PartialEvent? = null,
)
class PartialEvent(
val tags: Array<Array<String>>? = null,
val content: String? = null,
)
private fun parseResults(body: String): Nip96Result {
val mapper =
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, Nip96Result::class.java)
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.amethyst.model.Account
@@ -6,9 +26,11 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.BadgeAwardEvent
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
@@ -23,6 +45,7 @@ import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.ReactionEvent
@@ -31,9 +54,12 @@ import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.StatusEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.utils.TimeUtils
// TODO: Migrate this to a property of AccountVi
object NostrAccountDataSource : NostrDataSource("AccountData") {
lateinit var account: Account
var otherAccounts = listOf<HexKey>()
val latestEOSEs = EOSEAccount()
val hasLoadedTheBasics = mutableMapOf<User, Boolean>()
@@ -41,175 +67,200 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
fun createAccountContactListFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
)
filter =
JsonFilter(
kinds = listOf(ContactListEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1,
),
)
}
fun createAccountMetadataFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
)
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1,
),
)
}
fun createAccountRelayListFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(AdvertisedRelayListEvent.kind, StatusEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 5
)
filter =
JsonFilter(
kinds = listOf(AdvertisedRelayListEvent.KIND, StatusEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 5,
),
)
}
fun createOtherAccountsBaseFilter(): TypedFilter? {
if (otherAccounts.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
MetadataEvent.KIND,
ContactListEvent.KIND,
AdvertisedRelayListEvent.KIND,
MuteListEvent.KIND,
PeopleListEvent.KIND,
),
authors = otherAccounts.filter { it != account.userProfile().pubkeyHex },
limit = 100,
),
)
}
fun createAccountAcceptedAwardsFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BadgeProfilesEvent.kind, EmojiPackSelectionEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 10
)
filter =
JsonFilter(
kinds = listOf(BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 10,
),
)
}
fun createAccountBookmarkListFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 100
)
filter =
JsonFilter(
kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 100,
),
)
}
fun createAccountReportsFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
)
filter =
JsonFilter(
kinds = listOf(ReportEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultNotificationFollowList.value)
?.relayList,
),
)
}
fun createAccountLastPostsListFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
authors = listOf(account.userProfile().pubkeyHex),
limit = 400
)
filter =
JsonFilter(
authors = listOf(account.userProfile().pubkeyHex),
limit = 400,
),
)
}
fun createNotificationFilter() = TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind,
PollNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
LnZapPaymentResponseEvent.kind,
ChannelMessageEvent.kind,
BadgeAwardEvent.kind
),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 4000,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
fun createNotificationFilter(): TypedFilter {
val since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultNotificationFollowList.value)
?.relayList
?: account.activeRelays()?.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
PollNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
LnZapPaymentResponseEvent.KIND,
ChannelMessageEvent.KIND,
BadgeAwardEvent.KIND,
),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 4000,
since = since,
),
)
)
}
fun createGiftWrapsToMeFilter() = TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(GiftWrapEvent.kind),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
fun createGiftWrapsToMeFilter() =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(GiftWrapEvent.KIND),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
),
)
)
val accountChannel = requestNewChannel { time, relayUrl ->
if (hasLoadedTheBasics[account.userProfile()] != null) {
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultNotificationFollowList, relayUrl, time)
} else {
hasLoadedTheBasics[account.userProfile()] = true
val accountChannel =
requestNewChannel { time, relayUrl ->
if (hasLoadedTheBasics[account.userProfile()] != null) {
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultNotificationFollowList.value,
relayUrl,
time,
)
} else {
hasLoadedTheBasics[account.userProfile()] = true
invalidateFilters()
invalidateFilters()
}
}
}
override fun consume(event: Event, relay: Relay) {
override fun consume(
event: Event,
relay: Relay,
) {
checkNotInMainThread()
if (LocalCache.justVerify(event)) {
if (event is GiftWrapEvent) {
val privateKey = account.keyPair.privKey
if (privateKey != null) {
event.cachedGift(privateKey)?.let {
this.consume(it, relay)
}
} else if (account.loginWithExternalSigner) {
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
if (cached == null) {
ExternalSignerUtils.decrypt(
event.content,
event.pubKey,
event.id,
SignerType.NIP44_DECRYPT
)
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
}
event.cachedGift(account.keyPair.pubKey, cached)?.let {
this.consume(it, relay)
}
}
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
event.cachedGift(account.signer) { this.consume(it, relay) }
}
if (event is SealedGossipEvent) {
val privateKey = account.keyPair.privKey
if (privateKey != null) {
event.cachedGossip(privateKey)?.let {
LocalCache.justConsume(it, relay)
}
} else if (account.loginWithExternalSigner) {
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
if (cached == null) {
ExternalSignerUtils.decrypt(
event.content,
event.pubKey,
event.id,
SignerType.NIP44_DECRYPT
)
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
}
event.cachedGossip(account.keyPair.pubKey, cached)?.let {
LocalCache.justConsume(it, relay)
}
}
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
// Don't store sealed gossips to avoid rebroadcasting by mistake.
event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) }
} else {
LocalCache.justConsume(event, relay)
}
}
}
override fun markAsSeenOnRelay(eventId: String, relay: Relay) {
override fun markAsSeenOnRelay(
eventId: String,
relay: Relay,
) {
checkNotInMainThread()
super.markAsSeenOnRelay(eventId, relay)
@@ -221,54 +272,76 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
markInnerAsSeenOnRelay(noteEvent, privKey, relay)
}
private fun markInnerAsSeenOnRelay(noteEvent: EventInterface, privKey: ByteArray, relay: Relay) {
private fun markInnerAsSeenOnRelay(
noteEvent: EventInterface,
privKey: ByteArray,
relay: Relay,
) {
LocalCache.getNoteIfExists(noteEvent.id())?.addRelay(relay)
if (noteEvent is GiftWrapEvent) {
val gift = noteEvent.cachedGift(privKey) ?: return
markInnerAsSeenOnRelay(gift, privKey, relay)
noteEvent.cachedGift(account.signer) { gift -> markInnerAsSeenOnRelay(gift, privKey, relay) }
} else if (noteEvent is SealedGossipEvent) {
val rumor = noteEvent.cachedGossip(privKey) ?: return
markInnerAsSeenOnRelay(rumor, privKey, relay)
noteEvent.cachedGossip(account.signer) { rumor ->
markInnerAsSeenOnRelay(rumor, privKey, relay)
}
}
}
override fun updateChannelFilters() {
return if (hasLoadedTheBasics[account.userProfile()] != null) {
// gets everthing about the user logged in
accountChannel.typedFilters = listOf(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
createNotificationFilter(),
createGiftWrapsToMeFilter(),
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter(),
createAccountBookmarkListFilter(),
createAccountLastPostsListFilter()
).ifEmpty { null }
// gets everything about the user logged in
accountChannel.typedFilters =
listOfNotNull(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
createNotificationFilter(),
createGiftWrapsToMeFilter(),
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter(),
createAccountBookmarkListFilter(),
createAccountLastPostsListFilter(),
createOtherAccountsBaseFilter(),
)
.ifEmpty { null }
} else {
// just the basics.
accountChannel.typedFilters = listOf(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
createAccountBookmarkListFilter()
).ifEmpty { null }
accountChannel.typedFilters =
listOf(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
createAccountBookmarkListFilter(),
)
.ifEmpty { null }
}
}
override fun auth(relay: Relay, challenge: String) {
override fun auth(
relay: Relay,
challenge: String,
) {
super.auth(relay, challenge)
if (this::account.isInitialized) {
val event = account.createAuthEvent(relay, challenge)
if (event != null) {
account.createAuthEvent(relay, challenge) {
Client.send(
event,
relay.url
it,
relay.url,
)
}
}
}
override fun notify(
relay: Relay,
description: String,
) {
super.notify(relay, description)
if (this::account.isInitialized) {
account.addPaymentRequestIfNew(Account.PaymentRequest(relay.url, description))
}
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.amethyst.model.Account
@@ -14,7 +34,10 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
var account: Account? = null
var channel: Channel? = null
fun loadMessagesBetween(account: Account, channel: Channel) {
fun loadMessagesBetween(
account: Account,
channel: Channel,
) {
this.account = account
this.channel = channel
resetFilters()
@@ -34,11 +57,12 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
// this guarantees that messages from the author do not disappear.
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50
)
filter =
JsonFilter(
kinds = listOf(ChannelMessageEvent.KIND),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50,
),
)
} else if (channel is LiveActivitiesChannel) {
// Brings on messages by the user from all other relays.
@@ -46,11 +70,12 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
// this guarantees that messages from the author do not disappear.
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.kind),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50
)
filter =
JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50,
),
)
}
return null
@@ -60,20 +85,22 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
if (channel is PublicChatChannel) {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
limit = 200
)
filter =
JsonFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
limit = 200,
),
)
} else if (channel is LiveActivitiesChannel) {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.kind),
tags = mapOf("a" to listOfNotNull(channel?.idHex)),
limit = 200
)
filter =
JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
tags = mapOf("a" to listOfNotNull(channel?.idHex)),
limit = 200,
),
)
}
return null
@@ -82,9 +109,11 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
val messagesChannel = requestNewChannel()
override fun updateChannelFilters() {
messagesChannel.typedFilters = listOfNotNull(
createMessagesToChannelFilter(),
createMessagesByMeToChannelFilter()
).ifEmpty { null }
messagesChannel.typedFilters =
listOfNotNull(
createMessagesToChannelFilter(),
createMessagesByMeToChannelFilter(),
)
.ifEmpty { null }
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.amethyst.model.Account
@@ -14,7 +34,10 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
private val latestEOSEs = EOSEAccount()
fun loadMessagesBetween(accountIn: Account, withRoom: ChatroomKey) {
fun loadMessagesBetween(
accountIn: Account,
withRoom: ChatroomKey,
) {
this.account = accountIn
this.withRoom = withRoom
resetFilters()
@@ -26,12 +49,17 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
return if (myPeer != null) {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = myPeer.users.map { it },
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(withRoom.hashCode().toString())?.relayList
)
filter =
JsonFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = myPeer.users.map { it },
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(withRoom.hashCode().toString())
?.relayList,
),
)
} else {
null
@@ -44,12 +72,17 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
return if (myPeer != null) {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
tags = mapOf("p" to myPeer.users.map { it }),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(withRoom.hashCode().toString())?.relayList
)
filter =
JsonFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
tags = mapOf("p" to myPeer.users.map { it }),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(withRoom.hashCode().toString())
?.relayList,
),
)
} else {
null
@@ -60,14 +93,17 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
latestEOSEs.removeDataFor(account.userProfile())
}
val inandoutChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), withRoom.hashCode().toString(), relayUrl, time)
}
val inandoutChannel =
requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), withRoom.hashCode().toString(), relayUrl, time)
}
override fun updateChannelFilters() {
inandoutChannel.typedFilters = listOfNotNull(
createMessagesToMeFilter(),
createMessagesFromMeFilter()
).ifEmpty { null }
inandoutChannel.typedFilters =
listOfNotNull(
createMessagesToMeFilter(),
createMessagesFromMeFilter(),
)
.ifEmpty { null }
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.amethyst.model.Account
@@ -17,57 +37,75 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
val latestEOSEs = EOSEAccount()
val chatRoomList = "ChatroomList"
fun createMessagesToMeFilter() = TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
fun createMessagesToMeFilter() =
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
JsonFilter(
kinds = listOf(PrivateDmEvent.KIND),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since =
latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
),
)
)
fun createMessagesFromMeFilter() = TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
fun createMessagesFromMeFilter() =
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
JsonFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
),
)
)
fun createChannelsCreatedbyMeFilter() = TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
fun createChannelsCreatedbyMeFilter() =
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
),
)
)
fun createMyChannelsFilter(): TypedFilter {
fun createMyChannelsFilter(): TypedFilter? {
val followingEvents = account.selectedChatsFollowList()
if (followingEvents.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES, // Metadata comes from any relay
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = followingEvents.toList(),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
)
// Metadata comes from any relay
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ChannelCreateEvent.KIND),
ids = followingEvents.toList(),
since =
latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
),
)
}
fun createLastChannelInfoFilter(): List<TypedFilter>? {
val followingEvents = account.selectedChatsFollowList()
if (followingEvents.isEmpty()) return null
return followingEvents.map {
TypedFilter(
types = COMMON_FEED_TYPES, // Metadata comes from any relay
filter = JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to listOf(it)),
limit = 1
)
// Metadata comes from any relay
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ChannelMetadataEvent.KIND),
tags = mapOf("e" to listOf(it)),
limit = 1,
),
)
}
}
@@ -75,34 +113,44 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
fun createLastMessageOfEachChannelFilter(): List<TypedFilter>? {
val followingEvents = account.selectedChatsFollowList()
if (followingEvents.isEmpty()) return null
return followingEvents.map {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(it)),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
limit = 50 // Remember to consider spam that is being removed from the UI
)
filter =
JsonFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOf(it)),
since =
latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
// Remember to consider spam that is being removed from the UI
limit = 50,
),
)
}
}
val chatroomListChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), chatRoomList, relayUrl, time)
}
val chatroomListChannel =
requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), chatRoomList, relayUrl, time)
}
override fun updateChannelFilters() {
val list = listOf(
createMessagesToMeFilter(),
createMessagesFromMeFilter(),
createMyChannelsFilter()
)
val list =
listOfNotNull(
createMessagesToMeFilter(),
createMessagesFromMeFilter(),
createMyChannelsFilter(),
)
chatroomListChannel.typedFilters = listOfNotNull(
list,
createLastChannelInfoFilter(),
createLastMessageOfEachChannelFilter()
).flatten().ifEmpty { null }
chatroomListChannel.typedFilters =
listOfNotNull(
list,
createLastChannelInfoFilter(),
createLastMessageOfEachChannelFilter(),
)
.flatten()
.ifEmpty { null }
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.amethyst.model.AddressableNote
@@ -17,14 +37,20 @@ object NostrCommunityDataSource : NostrDataSource("SingleCommunityFeed") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
authors = community.moderators().map { it.key }.plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)),
tags = mapOf(
"a" to listOf(myCommunityToWatch.address.toTag())
filter =
JsonFilter(
authors =
community
.moderators()
.map { it.key }
.plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)),
tags =
mapOf(
"a" to listOf(myCommunityToWatch.address.toTag()),
),
kinds = listOf(CommunityPostApprovalEvent.KIND),
limit = 500,
),
kinds = listOf(CommunityPostApprovalEvent.kind),
limit = 500
)
)
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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.util.Log
@@ -10,6 +30,7 @@ import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
@@ -20,70 +41,115 @@ abstract class NostrDataSource(val debugName: String) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var subscriptions = mapOf<String, Subscription>()
data class Counter(var counter: Int)
private var eventCounter = mapOf<String, Counter>()
var changingFilters = AtomicBoolean()
private var active: Boolean = false
fun printCounter() {
eventCounter.forEach {
Log.d("STATE DUMP", "Received Events ${it.key}: ${it.value.counter}")
Log.d(
"STATE DUMP ${this.javaClass.simpleName}",
"Received Events ${it.key}: ${it.value.counter}",
)
}
}
private val clientListener = object : Client.Listener() {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
if (subscriptionId in subscriptions.keys) {
val key = "$debugName $subscriptionId ${event.kind}"
val keyValue = eventCounter.get(key)
if (keyValue != null) {
keyValue.counter++
} else {
eventCounter = eventCounter + Pair(key, Counter(1))
private val clientListener =
object : Client.Listener() {
override fun onEvent(
event: Event,
subscriptionId: String,
relay: Relay,
afterEOSE: Boolean,
) {
if (subscriptions.containsKey(subscriptionId)) {
val key = "$debugName $subscriptionId ${event.kind}"
val keyValue = eventCounter.get(key)
if (keyValue != null) {
keyValue.counter++
} else {
eventCounter = eventCounter + Pair(key, Counter(1))
}
// Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url}: ${event.kind}")
consume(event, relay)
if (afterEOSE) {
markAsEOSE(subscriptionId, relay)
}
}
}
consume(event, relay)
override fun onError(
error: Error,
subscriptionId: String,
relay: Relay,
) {
// if (subscriptions.containsKey(subscriptionId)) {
// Log.e(
// this@NostrDataSource.javaClass.simpleName,
// "Relay OnError ${relay.url}: ${error.message}"
// )
// }
}
override fun onRelayStateChange(
type: Relay.StateType,
relay: Relay,
subscriptionId: String?,
) {
// if (subscriptions.containsKey(subscriptionId)) {
// Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url} ${subscriptionId}
// ${type.name}")
// }
if (
type == Relay.StateType.EOSE &&
subscriptionId != null &&
subscriptions.containsKey(subscriptionId)
) {
markAsEOSE(subscriptionId, relay)
}
}
override fun onSendResponse(
eventId: String,
success: Boolean,
message: String,
relay: Relay,
) {
if (success) {
markAsSeenOnRelay(eventId, relay)
}
}
override fun onAuth(
relay: Relay,
challenge: String,
) {
auth(relay, challenge)
}
override fun onNotify(
relay: Relay,
description: String,
) {
notify(relay, description)
}
}
override fun onError(error: Error, subscriptionId: String, relay: Relay) {
// Log.e("ERROR", "Relay ${relay.url}: ${error.message}")
}
override fun onRelayStateChange(type: Relay.StateType, relay: Relay, subscriptionId: String?) {
// Log.d("RELAY", "Relay ${relay.url} ${when (type) {
// Relay.Type.CONNECT -> "connected."
// Relay.Type.DISCONNECT -> "disconnected."
// Relay.Type.DISCONNECTING -> "disconnecting."
// Relay.Type.EOSE -> "sent all events it had stored."
// }}")
if (type == Relay.StateType.EOSE && subscriptionId != null && subscriptionId in subscriptions.keys) {
// updates a per subscripton since date
subscriptions[subscriptionId]?.updateEOSE(
TimeUtils.fiveMinutesAgo(), // in case people's clock is slighly off.
relay.url
)
}
}
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
if (success) {
markAsSeenOnRelay(eventId, relay)
}
}
override fun onAuth(relay: Relay, challenge: String) {
auth(relay, challenge)
}
}
init {
Log.d("Init", "${this.javaClass.simpleName} Subscribe")
Log.d(this.javaClass.simpleName, "${this.javaClass.simpleName} Subscribe")
Client.subscribe(clientListener)
}
fun destroy() {
// makes sure to run
Log.d(this.javaClass.simpleName, "${this.javaClass.simpleName} Unsubscribe")
stop()
Client.unsubscribe(clientListener)
scope.cancel()
@@ -92,14 +158,29 @@ abstract class NostrDataSource(val debugName: String) {
open fun start() {
println("DataSource: ${this.javaClass.simpleName} Start")
active = true
resetFilters()
}
open fun stop() {
active = false
println("DataSource: ${this.javaClass.simpleName} Stop")
subscriptions.values.forEach { channel ->
Client.close(channel.id)
channel.typedFilters = null
GlobalScope.launch(Dispatchers.IO) {
subscriptions.values.forEach { subscription ->
Client.close(subscription.id)
subscription.typedFilters = null
}
}
}
open fun stopSync() {
active = false
println("DataSource: ${this.javaClass.simpleName} Stop")
subscriptions.values.forEach { subscription ->
Client.close(subscription.id)
subscription.typedFilters = null
}
}
@@ -119,7 +200,7 @@ abstract class NostrDataSource(val debugName: String) {
fun invalidateFilters() {
scope.launch(Dispatchers.IO) {
bundler.invalidate() {
bundler.invalidate {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// adds the time to perform the refresh into this delay
@@ -130,12 +211,11 @@ abstract class NostrDataSource(val debugName: String) {
}
fun resetFilters() {
scope.launch(Dispatchers.IO) {
resetFiltersSuspend()
}
scope.launch(Dispatchers.IO) { resetFiltersSuspend() }
}
fun resetFiltersSuspend() {
println("DataSource: ${this.javaClass.simpleName} resetFiltersSuspend $active")
checkNotInMainThread()
// saves the channels that are currently active
@@ -149,29 +229,51 @@ abstract class NostrDataSource(val debugName: String) {
// Makes sure to only send an updated filter when it actually changes.
subscriptions.values.forEach { updatedSubscription ->
val updatedSubscriotionNewFilters = updatedSubscription.typedFilters
val updatedSubscriptionNewFilters = updatedSubscription.typedFilters
if (updatedSubscription.id in currentFilters.keys) {
if (updatedSubscriotionNewFilters == null) {
// was active and is not active anymore, just close.
Client.close(updatedSubscription.id)
} else {
// was active and is still active, check if it has changed.
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
Client.close(updatedSubscription.id)
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
} else {
// hasn't changed, does nothing.
Client.sendFilterOnlyIfDisconnected(updatedSubscription.id, updatedSubscriotionNewFilters)
}
val isActive = Client.isActive(updatedSubscription.id)
if (!isActive && updatedSubscriptionNewFilters != null) {
// Filter was removed from the active list
if (active) {
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
}
} else {
if (updatedSubscriotionNewFilters == null) {
// was not active and is still not active, does nothing
if (currentFilters.containsKey(updatedSubscription.id)) {
if (updatedSubscriptionNewFilters == null) {
// was active and is not active anymore, just close.
Client.close(updatedSubscription.id)
} else {
// was active and is still active, check if it has changed.
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
Client.close(updatedSubscription.id)
if (active) {
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
}
} else {
// hasn't changed, does nothing.
if (active) {
Client.sendFilterOnlyIfDisconnected(
updatedSubscription.id,
updatedSubscriptionNewFilters,
)
}
}
}
} else {
// was not active and becomes active, sends the filter.
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
if (updatedSubscriptionNewFilters == null) {
// was not active and is still not active, does nothing
} else {
// was not active and becomes active, sends the filter.
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
if (active) {
Log.d(
this@NostrDataSource.javaClass.simpleName,
"Update Filter 3 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}",
)
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
}
}
}
}
}
@@ -180,14 +282,40 @@ abstract class NostrDataSource(val debugName: String) {
changingFilters.getAndSet(false)
}
open fun consume(event: Event, relay: Relay) {
open fun consume(
event: Event,
relay: Relay,
) {
LocalCache.verifyAndConsume(event, relay)
}
open fun markAsSeenOnRelay(eventId: String, relay: Relay) {
open fun markAsSeenOnRelay(
eventId: String,
relay: Relay,
) {
LocalCache.getNoteIfExists(eventId)?.addRelay(relay)
}
open fun markAsEOSE(
subscriptionId: String,
relay: Relay,
) {
subscriptions[subscriptionId]?.updateEOSE(
// in case people's clock is slighly off.
TimeUtils.oneMinuteAgo(),
relay.url,
)
}
abstract fun updateChannelFilters()
open fun auth(relay: Relay, challenge: String) = Unit
open fun auth(
relay: Relay,
challenge: String,
) = Unit
open fun notify(
relay: Relay,
description: String,
) = Unit
}

View File

@@ -1,5 +1,26 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -8,218 +29,395 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
lateinit var account: Account
val scope = Amethyst.instance.applicationIOScope
val latestEOSEs = EOSEAccount()
fun createLiveStreamFilter(): List<TypedFilter> {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
var job: Job? = null
override fun start() {
job?.cancel()
job =
scope.launch(Dispatchers.IO) {
account.liveDiscoveryFollowLists.collect {
if (this@NostrDiscoveryDataSource::account.isInitialized) {
invalidateFilters()
}
}
}
super.start()
}
override fun stop() {
super.stop()
job?.cancel()
}
fun createMarketplaceFilter(): List<TypedFilter> {
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
val geohashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = follows,
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
filter =
JsonFilter(
authors = follows,
kinds = listOf(ClassifiedsEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
),
hashToLoad?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(ClassifiedsEvent.KIND),
tags =
mapOf(
"t" to
it
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
},
geohashToLoad?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(ClassifiedsEvent.KIND),
tags =
mapOf(
"g" to
it
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
},
)
}
fun createLiveStreamFilter(): List<TypedFilter> {
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
authors = follows,
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
),
follows?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
tags = mapOf("p" to it),
kinds = listOf(LiveActivitiesEvent.kind),
limit = 100,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
filter =
JsonFilter(
tags = mapOf("p" to it),
kinds = listOf(LiveActivitiesEvent.KIND),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
},
)
}
fun createPublicChatFilter(): List<TypedFilter> {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
val followChats = account.selectedChatsFollowList().toList()
return listOf(
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
authors = follows,
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
filter =
JsonFilter(
authors = follows,
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
),
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
ids = followChats,
kinds = listOf(ChannelCreateEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
if (followChats.isNotEmpty()) {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
ids = followChats,
kinds = listOf(ChannelCreateEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
)
} else {
null
},
)
}
fun createCommunitiesFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = follows,
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
filter =
JsonFilter(
authors = follows,
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createLiveStreamTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
filter =
JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
)
}
fun createLiveStreamGeohashesFilter(): TypedFilter? {
val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList)
val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
tags = mapOf(
"g" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
filter =
JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
)
}
fun createPublicChatsTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
filter =
JsonFilter(
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
)
}
fun createPublicChatsGeohashesFilter(): TypedFilter? {
val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList)
val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
tags = mapOf(
"g" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
filter =
JsonFilter(
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
)
}
fun createCommunitiesTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
filter =
JsonFilter(
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
)
}
fun createCommunitiesGeohashesFilter(): TypedFilter? {
val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList)
val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
tags = mapOf(
"g" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
filter =
JsonFilter(
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
)
}
val discoveryFeedChannel = requestNewChannel() { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultDiscoveryFollowList, relayUrl, time)
}
val discoveryFeedChannel =
requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultDiscoveryFollowList.value,
relayUrl,
time,
)
}
override fun updateChannelFilters() {
discoveryFeedChannel.typedFilters = createLiveStreamFilter().plus(createPublicChatFilter()).plus(
listOfNotNull(
createLiveStreamTagsFilter(),
createLiveStreamGeohashesFilter(),
createCommunitiesFilter(),
createPublicChatsTagsFilter(),
createCommunitiesTagsFilter(),
createCommunitiesGeohashesFilter(),
createPublicChatsGeohashesFilter()
)
).ifEmpty { null }
discoveryFeedChannel.typedFilters =
createLiveStreamFilter()
.plus(createPublicChatFilter())
.plus(createMarketplaceFilter())
.plus(
listOfNotNull(
createLiveStreamTagsFilter(),
createLiveStreamGeohashesFilter(),
createCommunitiesFilter(),
createCommunitiesTagsFilter(),
createCommunitiesGeohashesFilter(),
createPublicChatsTagsFilter(),
createPublicChatsGeohashesFilter(),
),
)
.ifEmpty { null }
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
@@ -21,15 +41,29 @@ object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
tags = mapOf(
"g" to listOf(
hashToLoad
)
filter =
JsonFilter(
tags =
mapOf(
"g" to
listOf(
hashToLoad,
),
),
kinds =
listOf(
TextNoteEvent.KIND,
ChannelMessageEvent.KIND,
LongTextNoteEvent.KIND,
PollNoteEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
),
limit = 200,
),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, LiveActivitiesChatMessageEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, AudioHeaderEvent.kind),
limit = 200
)
)
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
@@ -21,18 +41,32 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
tags = mapOf(
"t" to listOf(
hashToLoad,
hashToLoad.lowercase(),
hashToLoad.uppercase(),
hashToLoad.capitalize()
)
filter =
JsonFilter(
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,
),
limit = 200,
),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, LiveActivitiesChatMessageEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, AudioHeaderEvent.kind),
limit = 200
)
)
}

View File

@@ -1,7 +1,27 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
@@ -19,145 +39,197 @@ import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
object NostrHomeDataSource : NostrDataSource("HomeFeed") {
lateinit var account: Account
val scope = Amethyst.instance.applicationIOScope
val latestEOSEs = EOSEAccount()
private val cacheListener: (UserState) -> Unit = {
invalidateFilters()
}
var job: Job? = null
@OptIn(DelicateCoroutinesApi::class)
override fun start() {
if (this::account.isInitialized) {
GlobalScope.launch(Dispatchers.Main) {
account.userProfile().live().follows.observeForever(cacheListener)
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()
}
}
}
}
super.start()
}
@OptIn(DelicateCoroutinesApi::class)
override fun stop() {
super.stop()
if (this::account.isInitialized) {
GlobalScope.launch(Dispatchers.Main) {
account.userProfile().live().follows.removeObserver(cacheListener)
}
}
job?.cancel()
}
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList)
val follows = account.liveHomeFollowLists.value?.users
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null }
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
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
filter =
JsonFilter(
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,
),
authors = followSet,
limit = 400,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.relayList,
),
authors = followSet,
limit = 400,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
)
)
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
val hashToLoad = account.liveHomeFollowLists.value?.hashtags ?: return null
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioHeaderEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.relayList,
),
limit = 100,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
)
)
}
fun createFollowGeohashesFilter(): TypedFilter? {
val hashToLoad = account.selectedGeohashesFollowList(account.defaultHomeFollowList) ?: emptySet()
val hashToLoad = account.liveHomeFollowLists.value?.geotags ?: return null
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioHeaderEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
tags = mapOf(
"g" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.relayList,
),
limit = 100,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
)
)
}
fun createFollowCommunitiesFilter(): TypedFilter? {
val communitiesToLoad = account.selectedCommunitiesFollowList(account.defaultHomeFollowList) ?: emptySet()
val communitiesToLoad = account.liveHomeFollowLists.value?.communities ?: return null
if (communitiesToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind,
LongTextNoteEvent.kind,
ClassifiedsEvent.kind,
HighlightEvent.kind,
AudioHeaderEvent.kind,
AudioTrackEvent.kind,
PinListEvent.kind,
CommunityPostApprovalEvent.kind
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
CommunityPostApprovalEvent.KIND,
),
tags =
mapOf(
"a" to communitiesToLoad.toList(),
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.relayList,
),
tags = mapOf(
"a" to communitiesToLoad.toList()
),
limit = 100,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
)
)
}
val followAccountChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultHomeFollowList, relayUrl, time)
}
val followAccountChannel =
requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultHomeFollowList.value,
relayUrl,
time,
)
}
override fun updateChannelFilters() {
followAccountChannel.typedFilters = listOfNotNull(
createFollowAccountsFilter(),
createFollowCommunitiesFilter(),
createFollowTagsFilter(),
createFollowGeohashesFilter()
).ifEmpty { null }
followAccountChannel.typedFilters =
listOfNotNull(
createFollowAccountsFilter(),
createFollowCommunitiesFilter(),
createFollowTagsFilter(),
createFollowGeohashesFilter(),
)
.ifEmpty { null }
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.amethyst.service.relays.Client
@@ -7,29 +27,31 @@ import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.events.RelayAuthEvent
import com.vitorpamplona.quartz.signers.NostrSigner
class NostrLnZapPaymentResponseDataSource(
private val fromServiceHex: String,
private val toUserHex: String,
private val replyingToHex: String,
private val authSigningKey: ByteArray
private val authSigner: NostrSigner,
) : NostrDataSource("LnZapPaymentResponseFeed") {
val feedTypes = setOf(FeedType.WALLET_CONNECT)
private fun createWalletConnectServiceWatcher(): TypedFilter {
// downloads all the reactions to a given event.
return TypedFilter(
types = feedTypes,
filter = JsonFilter(
kinds = listOf(LnZapPaymentResponseEvent.kind),
authors = listOf(fromServiceHex),
tags = mapOf(
"e" to listOf(replyingToHex),
"p" to listOf(toUserHex)
filter =
JsonFilter(
kinds = listOf(LnZapPaymentResponseEvent.KIND),
authors = listOf(fromServiceHex),
tags =
mapOf(
"e" to listOf(replyingToHex),
"p" to listOf(toUserHex),
),
limit = 1,
),
limit = 1
)
)
}
@@ -41,13 +63,17 @@ class NostrLnZapPaymentResponseDataSource(
channel.typedFilters = listOfNotNull(wc).ifEmpty { null }
}
override fun auth(relay: Relay, challenge: String) {
override fun auth(
relay: Relay,
challenge: String,
) {
super.auth(relay, challenge)
val event = RelayAuthEvent.create(relay.url, challenge, "", authSigningKey)
Client.send(
event,
relay.url
)
RelayAuthEvent.create(relay.url, challenge, authSigner) {
Client.send(
it,
relay.url,
)
}
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
@@ -5,6 +25,7 @@ import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexValidator
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.AudioHeaderEvent
@@ -25,6 +46,7 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import kotlin.coroutines.cancellation.CancellationException
object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
private var searchString: String? = null
@@ -35,69 +57,91 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
return null
}
val hexToWatch = try {
Nip19.uriToRoute(mySearchString)?.hex ?: Hex.decode(mySearchString).toHexKey()
} catch (e: Exception) {
null
}
val hexToWatch =
try {
val isAStraightHex =
if (HexValidator.isHex(mySearchString)) {
Hex.decode(mySearchString).toHexKey()
} else {
null
}
Nip19.uriToRoute(mySearchString)?.hex ?: isAStraightHex
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
// downloads all the reactions to a given event.
return listOfNotNull(
hexToWatch?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
ids = listOfNotNull(hexToWatch)
)
filter =
JsonFilter(
ids = listOfNotNull(hexToWatch),
),
)
},
hexToWatch?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOfNotNull(hexToWatch)
)
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOfNotNull(hexToWatch),
),
)
},
TypedFilter(
types = setOf(FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
search = mySearchString,
limit = 100
)
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
search = mySearchString,
limit = 100,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind, BadgeDefinitionEvent.kind,
PeopleListEvent.kind, BookmarkListEvent.kind, AudioHeaderEvent.kind,
AudioTrackEvent.kind, PinListEvent.kind, PollNoteEvent.kind,
ChannelCreateEvent.kind
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
BadgeDefinitionEvent.KIND,
PeopleListEvent.KIND,
BookmarkListEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
PollNoteEvent.KIND,
ChannelCreateEvent.KIND,
),
search = mySearchString,
limit = 100,
),
search = mySearchString,
limit = 100
)
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(
ChannelMetadataEvent.kind,
ClassifiedsEvent.kind,
CommunityDefinitionEvent.kind,
EmojiPackEvent.kind,
HighlightEvent.kind,
LiveActivitiesEvent.kind,
PollNoteEvent.kind,
NNSEvent.kind
filter =
JsonFilter(
kinds =
listOf(
ChannelMetadataEvent.KIND,
ClassifiedsEvent.KIND,
CommunityDefinitionEvent.KIND,
EmojiPackEvent.KIND,
HighlightEvent.KIND,
LiveActivitiesEvent.KIND,
PollNoteEvent.KIND,
NNSEvent.KIND,
),
search = mySearchString,
limit = 100,
),
search = mySearchString,
limit = 100
)
)
),
)
}
@@ -108,11 +152,18 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
}
fun search(searchString: String) {
this.searchString = searchString
invalidateFilters()
if (this.searchString != searchString) {
println("DataSource: ${this.javaClass.simpleName} Search for $searchString")
this.searchString = searchString
invalidateFilters()
}
}
fun clear() {
searchString = null
if (searchString != null) {
println("DataSource: ${this.javaClass.simpleName} Clear")
searchString = null
invalidateFilters()
}
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.amethyst.model.Channel
@@ -23,16 +43,17 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
// downloads all the reactions to a given event.
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to reactionsToWatch)
)
filter =
JsonFilter(
kinds = listOf(ChannelMetadataEvent.KIND),
tags = mapOf("e" to reactionsToWatch),
),
)
}
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val directEventsToLoad = channelsToWatch
.filter { it.notes.isEmpty() && it is PublicChatChannel }
val directEventsToLoad =
channelsToWatch.filter { it.notes.isEmpty() && it is PublicChatChannel }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
@@ -43,17 +64,17 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
// downloads linked events to this event.
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = interestedEvents.toList()
)
filter =
JsonFilter(
kinds = listOf(ChannelCreateEvent.KIND),
ids = interestedEvents.toList(),
),
)
}
fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad = channelsToWatch
.filterIsInstance<LiveActivitiesChannel>()
.filter { it.info == null }
val directEventsToLoad =
channelsToWatch.filterIsInstance<LiveActivitiesChannel>().filter { it.info == null }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
@@ -66,11 +87,12 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
it.address().let { aTag ->
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex)
)
filter =
JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),
),
)
}
}
@@ -83,9 +105,8 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
val missing = createLoadEventsIfNotLoadedFilter()
val missingStreaming = createLoadStreamingIfNotLoadedFilter()
singleChannelChannel.typedFilters = (
(listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())
).ifEmpty { null }
singleChannelChannel.typedFilters =
((listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())).ifEmpty { null }
}
fun add(eventId: Channel) {

View File

@@ -1,7 +1,28 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.JsonFilter
@@ -21,34 +42,40 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
private var addressesToWatch = setOf<Note>()
private fun createReactionsToWatchInAddressFilter(): List<TypedFilter>? {
val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch
val addressesToWatch =
(
eventsToWatch.filter { it.address() != null } +
addressesToWatch.filter { it.address() != null }
)
.toSet()
if (addressesToWatch.isEmpty()) {
return null
}
return addressesToWatch.mapNotNull {
it.address()?.let { aTag ->
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
PollNoteEvent.kind,
CommunityPostApprovalEvent.kind,
LiveActivitiesChatMessageEvent.kind
),
tags = mapOf("a" to listOf(aTag.toTag())),
since = it.lastReactionsDownloadTime,
limit = 1000 // Max amount of "replies" to download on a specific event.
)
)
}
return groupByEOSEPresence(addressesToWatch).map {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
CommunityPostApprovalEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
),
tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
)
}
}
@@ -64,21 +91,23 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
if (aTag.kind < 25000 && aTag.dTag.isBlank()) {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(aTag.kind),
authors = listOf(aTag.pubKeyHex),
limit = 1000 // Max amount of "replies" to download on a specific event.
)
filter =
JsonFilter(
kinds = listOf(aTag.kind),
authors = listOf(aTag.pubKeyHex),
limit = 5,
),
)
} else {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),
limit = 1000 // Max amount of "replies" to download on a specific event.
)
filter =
JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),
limit = 5,
),
)
}
}
@@ -86,45 +115,44 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
}
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
val reactionsToWatch = eventsToWatch
if (reactionsToWatch.isEmpty()) {
if (eventsToWatch.isEmpty()) {
return null
}
return reactionsToWatch.map {
return groupByEOSEPresence(eventsToWatch).map {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
PollNoteEvent.kind
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
),
tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime,
limit = 1000 // Max amount of "replies" to download on a specific event.
)
)
}
}
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad = eventsToWatch
.filter { it.event == null }
val directEventsToLoad = eventsToWatch.filter { it.event == null }
val threadingEventsToLoad = eventsToWatch
.mapNotNull { it.replyTo }
.flatten()
.filter { it !is AddressableNote && it.event == null }
val threadingEventsToLoad =
eventsToWatch
.mapNotNull { it.replyTo }
.flatten()
.filter { it !is AddressableNote && it.event == null }
val interestedEvents =
(directEventsToLoad + threadingEventsToLoad)
.map { it.idHex }.toSet()
val interestedEvents = (directEventsToLoad + threadingEventsToLoad).map { it.idHex }.toSet()
if (interestedEvents.isEmpty()) {
return null
@@ -134,32 +162,43 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
ids = interestedEvents.toList()
)
)
filter =
JsonFilter(
ids = interestedEvents.toList(),
),
),
)
}
val singleEventChannel = requestNewChannel { time, relayUrl ->
// Ignores EOSE if it is in the middle of a filter change.
if (changingFilters.get()) return@requestNewChannel
val singleEventChannel =
requestNewChannel { time, relayUrl ->
// Ignores EOSE if it is in the middle of a filter change.
if (changingFilters.get()) return@requestNewChannel
checkNotInMainThread()
checkNotInMainThread()
eventsToWatch.forEach {
val eose = it.lastReactionsDownloadTime[relayUrl]
if (eose == null) {
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
eventsToWatch.forEach {
val eose = it.lastReactionsDownloadTime[relayUrl]
if (eose == null) {
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
}
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
}
addressesToWatch.forEach {
val eose = it.lastReactionsDownloadTime[relayUrl]
if (eose == null) {
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
}
override fun updateChannelFilters() {
val reactions = createRepliesAndReactionsFilter()
@@ -167,7 +206,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
val addresses = createAddressFilter()
val addressReactions = createReactionsToWatchInAddressFilter()
singleEventChannel.typedFilters = listOfNotNull(missing, addresses, reactions, addressReactions).flatten().ifEmpty { null }
singleEventChannel.typedFilters =
listOfNotNull(missing, addresses, reactions, addressReactions).flatten().ifEmpty { null }
}
fun add(eventId: Note) {
@@ -198,3 +238,45 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
}
}
}
fun groupByEOSEPresence(notes: Set<Note>): Collection<List<Note>> {
return notes.groupBy { it.lastReactionsDownloadTime.keys.sorted().joinToString(",") }.values
}
fun groupByEOSEPresence(users: Iterable<User>): Collection<List<User>> {
return users.groupBy { it.latestEOSEs.keys.sorted().joinToString(",") }.values
}
fun findMinimumEOSEs(notes: List<Note>): Map<String, EOSETime> {
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
notes.forEach {
it.lastReactionsDownloadTime.forEach {
val minEose = minLatestEOSEs[it.key]
if (minEose == null) {
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
} else if (it.value.time < minEose.time) {
minEose.time = it.value.time
}
}
}
return minLatestEOSEs
}
fun findMinimumEOSEsForUsers(users: List<User>): Map<String, EOSETime> {
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
users.forEach {
it.latestEOSEs.forEach {
val minEose = minLatestEOSEs[it.key]
if (minEose == null) {
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
} else if (it.value.time < minEose.time) {
minEose.time = it.value.time
}
}
}
return minLatestEOSEs
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.amethyst.model.User
@@ -17,100 +37,81 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
val firstTimers = usersToWatch.filter { it.info?.latestMetadata == null }.map { it.pubkeyHex }
if (firstTimers.isEmpty()) return null
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind, StatusEvent.kind),
authors = firstTimers,
limit = 10 * firstTimers.size
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
authors = firstTimers,
),
),
)
}
fun createUserMetadataStatusReportFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
val secondTimers = usersToWatch.filter { it.info?.latestMetadata != null }
if (secondTimers.isEmpty()) return null
return groupByEOSEPresence(secondTimers)
.map { group ->
val groupIds = group.map { it.pubkeyHex }
val minEOSEs = findMinimumEOSEsForUsers(group)
listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND),
authors = groupIds,
since = minEOSEs,
),
),
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ReportEvent.KIND),
tags = mapOf("p" to groupIds),
since = minEOSEs,
),
),
)
)
)
}
fun createUserMetadataFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
if (usersToWatch.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(StatusEvent.kind),
authors = usersToWatch.map { it.pubkeyHex },
since = minLatestEOSEs
)
)
}
fun createUserStatusFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
if (usersToWatch.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(StatusEvent.kind),
authors = usersToWatch.map { it.pubkeyHex },
since = minLatestEOSEs
)
)
}
fun createUserReportFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
if (usersToWatch.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to usersToWatch.map { it.pubkeyHex }),
since = minLatestEOSEs
)
)
}
val userChannel = requestNewChannel() { time, relayUrl ->
usersToWatch.forEach {
val eose = it.latestEOSEs[relayUrl]
if (eose == null) {
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
.flatten()
}
val userChannelFirstTimers = requestNewChannel() { time, relayUrl ->
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
}
val userChannel =
requestNewChannel { time, relayUrl ->
checkNotInMainThread()
override fun updateChannelFilters() {
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
val neverGottenAnEOSE = mutableSetOf<String>()
usersToWatch.forEach {
if (it.latestEOSEs.isEmpty()) { // first time
neverGottenAnEOSE.add(it.pubkeyHex)
} else {
it.latestEOSEs.forEach {
val minEose = minLatestEOSEs[it.key]
if (minEose == null) {
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
} else if (it.value.time < minEose.time) {
minEose.time = it.value.time
usersToWatch.forEach {
if (it.info?.latestMetadata != null) {
val eose = it.latestEOSEs[relayUrl]
if (eose == null) {
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
}
}
userChannel.typedFilters = listOfNotNull(
createUserMetadataFilter(minLatestEOSEs),
createUserStatusFilter(minLatestEOSEs),
createUserReportFilter(minLatestEOSEs)
).ifEmpty { null }
userChannelFirstTimers.typedFilters = listOfNotNull(
createUserMetadataFilter()
).flatten().ifEmpty { null }
override fun updateChannelFilters() {
checkNotInMainThread()
userChannel.typedFilters =
listOfNotNull(
createUserMetadataFilter(),
createUserMetadataStatusReportFilter(),
)
.flatten()
.ifEmpty { null }
}
fun add(user: User) {

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.amethyst.model.ThreadAssembler
@@ -11,28 +31,36 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val threadToLoad = eventToWatch ?: return null
val eventsToLoad = ThreadAssembler().findThreadFor(threadToLoad)
.filter { it.event == null }
.map { it.idHex }
.toSet()
.ifEmpty { null } ?: return null
val eventsToLoad =
ThreadAssembler()
.findThreadFor(threadToLoad)
.filter { it.event == null }
.map { it.idHex }
.toSet()
.ifEmpty { null }
?: return null
if (eventsToLoad.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
ids = eventsToLoad.map { it.substring(0, 8) }
)
filter =
JsonFilter(
ids = eventsToLoad.toList(),
),
)
}
val loadEventsChannel = requestNewChannel() { _, _ ->
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
}
val loadEventsChannel =
requestNewChannel { _, _ ->
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
}
override fun updateChannelFilters() {
loadEventsChannel.typedFilters = listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
loadEventsChannel.typedFilters =
listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
}
fun loadThread(noteId: String?) {

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.amethyst.model.User
@@ -29,114 +49,134 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
this.user = user
}
fun createUserInfoFilter() = user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 1
fun createUserInfoFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
),
)
)
}
}
fun createUserPostsFilter() = user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind,
GenericRepostEvent.kind,
RepostEvent.kind,
LongTextNoteEvent.kind,
AudioTrackEvent.kind,
AudioHeaderEvent.kind,
PinListEvent.kind,
PollNoteEvent.kind,
HighlightEvent.kind
),
authors = listOf(it.pubkeyHex),
limit = 200
fun createUserPostsFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
GenericRepostEvent.KIND,
RepostEvent.KIND,
LongTextNoteEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
PinListEvent.KIND,
PollNoteEvent.KIND,
HighlightEvent.KIND,
),
authors = listOf(it.pubkeyHex),
limit = 200,
),
)
)
}
}
fun createUserReceivedZapsFilter() = user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(LnZapEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex))
fun createUserReceivedZapsFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(LnZapEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
),
)
)
}
}
fun createFollowFilter() = user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 1
fun createFollowFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ContactListEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
),
)
)
}
}
fun createFollowersFilter() = user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex))
fun createFollowersFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ContactListEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
),
)
)
}
}
fun createAcceptedAwardsFilter() = user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BadgeProfilesEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 1
fun createAcceptedAwardsFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(BadgeProfilesEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
),
)
)
}
}
fun createBookmarksFilter() = user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind, AppRecommendationEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 100
fun createBookmarksFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, AppRecommendationEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 100,
),
)
)
}
}
fun createReceivedAwardsFilter() = user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BadgeAwardEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex)),
limit = 20
fun createReceivedAwardsFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(BadgeAwardEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
limit = 20,
),
)
)
}
}
val userInfoChannel = requestNewChannel()
override fun updateChannelFilters() {
userInfoChannel.typedFilters = listOfNotNull(
createUserInfoFilter(),
createUserPostsFilter(),
createFollowFilter(),
createFollowersFilter(),
createUserReceivedZapsFilter(),
createAcceptedAwardsFilter(),
createReceivedAwardsFilter(),
createBookmarksFilter()
).ifEmpty { null }
userInfoChannel.typedFilters =
listOfNotNull(
createUserInfoFilter(),
createUserPostsFilter(),
createFollowFilter(),
createFollowersFilter(),
createUserReceivedZapsFilter(),
createAcceptedAwardsFilter(),
createReceivedAwardsFilter(),
createBookmarksFilter(),
)
.ifEmpty { null }
}
}

View File

@@ -1,5 +1,26 @@
/**
* Copyright (c) 2023 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 com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -7,75 +28,126 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
lateinit var account: Account
val scope = Amethyst.instance.applicationIOScope
val latestEOSEs = EOSEAccount()
fun createContextualFilter(): TypedFilter? {
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)?.toList()
var job: Job? = null
override fun start() {
job?.cancel()
job =
scope.launch(Dispatchers.IO) {
account.liveStoriesFollowLists.collect {
if (this@NostrVideoDataSource::account.isInitialized) {
invalidateFilters()
}
}
}
super.start()
}
override fun stop() {
super.stop()
job?.cancel()
}
fun createContextualFilter(): TypedFilter {
val follows = account.liveStoriesFollowLists.value?.users?.toList()
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = follows,
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
limit = 200,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
)
filter =
JsonFilter(
authors = follows,
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
limit = 200,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultStoriesFollowList.value)
?.relayList,
),
)
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultStoriesFollowList)
val hashToLoad = account.liveStoriesFollowLists.value?.hashtags?.toList() ?: return null
if (hashToLoad.isNullOrEmpty()) return null
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
filter =
JsonFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultStoriesFollowList.value)
?.relayList,
),
limit = 100,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
)
)
}
fun createFollowGeohashesFilter(): TypedFilter? {
val hashToLoad = account.selectedGeohashesFollowList(account.defaultStoriesFollowList)
val hashToLoad = account.liveStoriesFollowLists.value?.geotags?.toList() ?: return null
if (hashToLoad.isNullOrEmpty()) return null
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
tags = mapOf(
"g" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
filter =
JsonFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultStoriesFollowList.value)
?.relayList,
),
limit = 100,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
)
)
}
val videoFeedChannel = requestNewChannel() { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultStoriesFollowList, relayUrl, time)
}
val videoFeedChannel =
requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultStoriesFollowList.value,
relayUrl,
time,
)
}
override fun updateChannelFilters() {
videoFeedChannel.typedFilters = listOfNotNull(
createContextualFilter(),
createFollowTagsFilter(),
createFollowGeohashesFilter()
).ifEmpty { null }
videoFeedChannel.typedFilters =
listOfNotNull(
createContextualFilter(),
createFollowTagsFilter(),
createFollowGeohashesFilter(),
)
.ifEmpty { null }
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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.util.Log
@@ -5,14 +25,22 @@ import android.util.LruCache
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.BuildConfig
import okhttp3.Request
import kotlin.coroutines.cancellation.CancellationException
@Immutable
data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
@Immutable data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
object OnlineChecker {
val checkOnlineCache = LruCache<String, OnlineCheckResult>(100)
val fiveMinutes = 1000 * 60 * 5
fun isOnlineCached(url: String?): Boolean {
if (url.isNullOrBlank()) return false
if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) {
return checkOnlineCache.get(url).online
}
return false
}
fun isOnline(url: String?): Boolean {
checkNotInMainThread()
@@ -21,19 +49,25 @@ object OnlineChecker {
return checkOnlineCache.get(url).online
}
return try {
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
Log.d("OnlineChecker", "isOnline $url")
val result = HttpClient.getHttpClient().newCall(request).execute().use {
it.isSuccessful
}
return try {
val request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
val result =
HttpClientManager.getHttpClient().newCall(request).execute().use {
checkNotInMainThread()
it.isSuccessful
}
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
result
} catch (e: Exception) {
if (e is CancellationException) throw e
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false))
Log.e("LiveActivities", "Failed to check streaming url $url", e)
false

View File

@@ -1,17 +1,50 @@
/**
* Copyright (c) 2023 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.content.Intent
import android.net.Uri
object PackageUtils {
private fun isPackageInstalled(context: Context, target: String): Boolean {
return context.packageManager.getInstalledApplications(0).find { info -> info.packageName == target } != null
private fun isPackageInstalled(
context: Context,
target: String,
): Boolean {
return context.packageManager.getInstalledApplications(0).find { info ->
info.packageName == target
} != null
}
fun isOrbotInstalled(context: Context): Boolean {
return isPackageInstalled(context, "org.torproject.android")
}
fun isAmberInstalled(context: Context): Boolean {
return isPackageInstalled(context, "com.greenart7c3.nostrsigner")
fun isExternalSignerInstalled(context: Context): Boolean {
val intent =
Intent().apply {
action = Intent.ACTION_VIEW
data = Uri.parse("nostrsigner:")
}
val infos = context.packageManager.queryIntentActivities(intent, 0)
return infos.size > 0
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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
@@ -8,25 +28,24 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.events.ZapSplitSetup
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.round
class ZapPaymentHandler(val account: Account) {
@Immutable
data class Payable(
val info: ZapSplitSetup,
val user: User?,
val amountMilliSats: Long,
val invoice: String
val invoice: String,
)
suspend fun zap(
@@ -38,26 +57,33 @@ class ZapPaymentHandler(val account: Account) {
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
zapType: LnZapEvent.ZapType
zapType: LnZapEvent.ZapType,
) = withContext(Dispatchers.IO) {
val zapSplitSetup = note.event?.zapSplitSetup()
val zapsToSend = if (!zapSplitSetup.isNullOrEmpty()) {
zapSplitSetup
} else {
val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
val noteEvent = note.event
if (lud16.isNullOrBlank()) {
onError(
context.getString(R.string.missing_lud16),
context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats)
)
return@withContext
val zapsToSend =
if (!zapSplitSetup.isNullOrEmpty()) {
zapSplitSetup
} else if (noteEvent is LiveActivitiesEvent && noteEvent.hasHost()) {
noteEvent.hosts().map { ZapSplitSetup(it, null, weight = 1.0, false) }
} else {
val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
if (lud16.isNullOrBlank()) {
onError(
context.getString(R.string.missing_lud16),
context.getString(
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
),
)
return@withContext
}
listOf(ZapSplitSetup(lud16, null, weight = 1.0, true))
}
listOf(ZapSplitSetup(lud16, null, weight = 1.0, true))
}
val totalWeight = zapsToSend.sumOf { it.weight }
val invoicesToPayOnIntent = mutableListOf<Payable>()
@@ -66,8 +92,7 @@ class ZapPaymentHandler(val account: Account) {
val outerProgressMin = index / zapsToSend.size.toFloat()
val outerProgressMax = (index + 1) / zapsToSend.size.toFloat()
val zapValue =
round((amountMilliSats * value.weight / totalWeight) / 1000f).toLong() * 1000
val zapValue = round((amountMilliSats * value.weight / totalWeight) / 1000f).toLong() * 1000
if (value.isLnAddress) {
innerZap(
@@ -88,10 +113,10 @@ class ZapPaymentHandler(val account: Account) {
info = value,
user = null,
amountMilliSats = zapValue,
invoice = it
)
invoice = it,
),
)
}
},
)
} else {
val user = LocalCache.getUserIfExists(value.lnAddressOrPubKeyHex)
@@ -117,20 +142,20 @@ class ZapPaymentHandler(val account: Account) {
info = value,
user = user,
amountMilliSats = zapValue,
invoice = it
)
invoice = it,
),
)
}
},
)
} else {
onError(
context.getString(
R.string.missing_lud16
R.string.missing_lud16,
),
context.getString(
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
user?.toBestDisplayName() ?: value.lnAddressOrPubKeyHex
)
user?.toBestDisplayName() ?: value.lnAddressOrPubKeyHex,
),
)
}
}
@@ -142,12 +167,38 @@ class ZapPaymentHandler(val account: Account) {
} else {
launch(Dispatchers.IO) {
// Awaits for the event to come back to LocalCache.
delay(5000)
onProgress(1f)
var count = 0
while (invoicesToPayOnIntent.size < zapsToSend.size || count < 4) {
count++
Thread.sleep(5000)
}
if (invoicesToPayOnIntent.isNotEmpty()) {
onPayViaIntent(invoicesToPayOnIntent.toImmutableList())
onProgress(1f)
} else {
onProgress(1f)
}
}
}
}
private fun prepareZapRequestIfNeeded(
note: Note,
pollOption: Int?,
message: String,
zapType: LnZapEvent.ZapType,
overrideUser: User? = null,
onReady: (String?) -> Unit,
) {
if (zapType != LnZapEvent.ZapType.NONZAP) {
account.createZapRequestFor(note, pollOption, message, zapType, overrideUser) { zapRequest ->
onReady(zapRequest.toJson())
}
} else {
onReady(null)
}
}
private suspend fun innerZap(
lud16: String,
note: Note,
@@ -159,56 +210,51 @@ class ZapPaymentHandler(val account: Account) {
onProgress: (percent: Float) -> Unit,
onPayInvoiceThroughIntent: (String) -> Unit,
zapType: LnZapEvent.ZapType,
overrideUser: User? = null
overrideUser: User? = null,
) {
var zapRequestJson = ""
onProgress(0.05f)
if (zapType != LnZapEvent.ZapType.NONZAP) {
val zapRequest = account.createZapRequestFor(note, pollOption, message, zapType, overrideUser)
if (zapRequest != null) {
zapRequestJson = zapRequest.toJson()
}
}
prepareZapRequestIfNeeded(note, pollOption, message, zapType, overrideUser) { zapRequestJson ->
onProgress(0.10f)
onProgress(0.10f)
LightningAddressResolver().lnAddressInvoice(
lud16,
amount,
message,
zapRequestJson,
onSuccess = {
onProgress(0.7f)
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(
bolt11 = it,
note,
onResponse = { response ->
if (response is PayInvoiceErrorResponse) {
onProgress(0.0f)
onError(
context.getString(R.string.error_dialog_pay_invoice_error),
context.getString(
R.string.wallet_connect_pay_invoice_error_error,
response.error?.message
?: response.error?.code?.toString()
?: "Error parsing error message"
)
)
} else {
onProgress(1f)
}
LightningAddressResolver()
.lnAddressInvoice(
lud16,
amount,
message,
zapRequestJson,
onSuccess = {
onProgress(0.7f)
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(
bolt11 = it,
note,
onResponse = { response ->
if (response is PayInvoiceErrorResponse) {
onProgress(0.0f)
onError(
context.getString(R.string.error_dialog_pay_invoice_error),
context.getString(
R.string.wallet_connect_pay_invoice_error_error,
response.error?.message
?: response.error?.code?.toString() ?: "Error parsing error message",
),
)
} else {
onProgress(1f)
}
},
)
onProgress(0.8f)
} else {
onPayInvoiceThroughIntent(it)
onProgress(0f)
}
)
onProgress(0.8f)
} else {
onPayInvoiceThroughIntent(it)
onProgress(0f)
}
},
onError = onError,
onProgress = onProgress,
context = context
)
},
onError = onError,
onProgress = onProgress,
context = context,
)
}
}
}

View File

@@ -1,23 +1,42 @@
/**
* Copyright (c) 2023 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.lnurl
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.HttpClient
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.Lud06
import com.vitorpamplona.quartz.encoders.toLnUrl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Request
import java.math.BigDecimal
import java.math.RoundingMode
import java.net.URLEncoder
import kotlin.coroutines.cancellation.CancellationException
class LightningAddressResolver() {
val client = HttpClient.getHttpClient()
val client = HttpClientManager.getHttpClient()
fun assembleUrl(lnaddress: String): String? {
val parts = lnaddress.split("@")
@@ -33,12 +52,12 @@ class LightningAddressResolver() {
return null
}
private suspend fun fetchLightningAddressJson(
private fun fetchLightningAddressJson(
lnaddress: String,
onSuccess: suspend (String) -> Unit,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
context: Context
) = withContext(Dispatchers.IO) {
context: Context,
) {
checkNotInMainThread()
val url = assembleUrl(lnaddress)
@@ -48,17 +67,18 @@ class LightningAddressResolver() {
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup,
lnaddress
)
lnaddress,
),
)
return@withContext
return
}
try {
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
val request: Request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
client.newCall(request).execute().use {
if (it.isSuccessful) {
@@ -67,36 +87,42 @@ class LightningAddressResolver() {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.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,
R.string
.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()
)
it.code.toString(),
),
)
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct,
R.string
.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception,
url,
lnaddress
)
lnaddress,
e.suppressedExceptions.getOrNull(0)?.message ?: e.cause?.message ?: e.message,
),
)
}
}
suspend fun fetchLightningInvoice(
fun fetchLightningInvoice(
lnCallback: String,
milliSats: Long,
message: String,
nostrRequest: String? = null,
onSuccess: suspend (String) -> Unit,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
context: Context
) = withContext(Dispatchers.IO) {
context: Context,
) {
checkNotInMainThread()
val encodedMessage = URLEncoder.encode(message, "utf-8")
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
@@ -107,10 +133,11 @@ class LightningAddressResolver() {
url += "&nostr=$encodedNostrRequest"
}
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
val request: Request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
client.newCall(request).execute().use {
if (it.isSuccessful) {
@@ -118,32 +145,35 @@ class LightningAddressResolver() {
} else {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(R.string.could_not_fetch_invoice_from, lnCallback)
context.getString(R.string.could_not_fetch_invoice_from, lnCallback),
)
}
}
}
suspend fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String, String) -> Unit, context: Context) {
fun lnAddressToLnUrl(
lnaddress: String,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
fetchLightningAddressJson(
lnaddress,
onSuccess = {
onSuccess(it.toByteArray().toLnUrl())
},
onSuccess = { onSuccess(it.toByteArray().toLnUrl()) },
onError = onError,
context = context
context = context,
)
}
suspend fun lnAddressInvoice(
fun lnAddressInvoice(
lnaddress: String,
milliSats: Long,
message: String,
nostrRequest: String? = null,
onSuccess: suspend (String) -> Unit,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
context: Context
context: Context,
) {
val mapper = jacksonObjectMapper()
@@ -152,22 +182,28 @@ class LightningAddressResolver() {
onSuccess = { lnAddressJson ->
onProgress(0.4f)
val lnurlp = try {
mapper.readTree(lnAddressJson)
} catch (t: Throwable) {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup)
)
null
}
val lnurlp =
try {
mapper.readTree(lnAddressJson)
} catch (t: Throwable) {
if (t is CancellationException) throw t
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup,
),
)
null
}
val callback = lnurlp?.get("callback")?.asText()
if (callback == null) {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration)
context.getString(
R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration,
),
)
}
@@ -182,59 +218,79 @@ class LightningAddressResolver() {
onSuccess = {
onProgress(0.6f)
val lnInvoice = try {
mapper.readTree(it)
} catch (t: Throwable) {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(R.string.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup)
)
null
}
val lnInvoice =
try {
mapper.readTree(it)
} catch (t: Throwable) {
if (t is CancellationException) throw t
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string
.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup,
),
)
null
}
lnInvoice?.get("pr")?.asText()?.ifBlank { null }?.let { pr ->
// Forces LN Invoice amount to be the requested amount.
val expectedAmountInSats = BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong()
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
if (invoiceAmount.toLong() == expectedAmountInSats) {
onProgress(0.7f)
onSuccess(pr)
} else {
lnInvoice
?.get("pr")
?.asText()
?.ifBlank { null }
?.let { pr ->
// Forces LN Invoice amount to be the requested amount.
val expectedAmountInSats =
BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong()
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
if (invoiceAmount.toLong() == expectedAmountInSats) {
onProgress(0.7f)
onSuccess(pr)
} else {
onProgress(0.0f)
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.incorrect_invoice_amount_sats_from_it_should_have_been,
invoiceAmount.toLong().toString(),
lnaddress,
expectedAmountInSats.toString(),
),
)
}
}
?: lnInvoice
?.get("reason")
?.asText()
?.ifBlank { null }
?.let { reason ->
onProgress(0.0f)
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string
.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error,
reason,
),
)
}
?: run {
onProgress(0.0f)
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.incorrect_invoice_amount_sats_from_it_should_have_been,
invoiceAmount.toLong().toString(),
lnaddress,
expectedAmountInSats.toString()
)
R.string
.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json,
),
)
}
} ?: lnInvoice?.get("reason")?.asText()?.ifBlank { null }?.let { reason ->
onProgress(0.0f)
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error,
reason
)
)
} ?: run {
onProgress(0.0f)
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(R.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json)
)
}
},
onError = onError,
context
context,
)
}
},
onError = onError,
context
context,
)
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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.quartz.events.zaps
import com.vitorpamplona.amethyst.model.Note
@@ -10,13 +30,9 @@ object UserZaps {
return (
zaps
.mapNotNull { entry ->
entry.value?.let {
ZapReqResponse(entry.key, it)
}
}
.mapNotNull { entry -> entry.value?.let { ZapReqResponse(entry.key, it) } }
.sortedBy { (it.zapEvent.event as? LnZapEventInterface)?.amount() }
.reversed()
)
)
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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.app.NotificationManager
@@ -7,8 +27,6 @@ import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
import com.vitorpamplona.amethyst.service.SignerType
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
import com.vitorpamplona.amethyst.ui.note.showAmount
@@ -41,117 +59,56 @@ class EventNotificationConsumer(private val applicationContext: Context) {
}
}
private suspend fun consumeIfMatchesAccount(pushWrappedEvent: GiftWrapEvent, account: Account) {
val key = account.keyPair.privKey
if (account.loginWithExternalSigner) {
ExternalSignerUtils.account = account
var cached = ExternalSignerUtils.cachedDecryptedContent[pushWrappedEvent.id]
if (cached == null) {
ExternalSignerUtils.decrypt(
pushWrappedEvent.content,
pushWrappedEvent.pubKey,
pushWrappedEvent.id,
SignerType.NIP44_DECRYPT
)
cached = ExternalSignerUtils.cachedDecryptedContent[pushWrappedEvent.id] ?: ""
}
pushWrappedEvent.unwrap(cached)?.let { notificationEvent ->
if (!LocalCache.justVerify(notificationEvent)) return // invalid event
if (LocalCache.notes[notificationEvent.id] != null) return // already processed
private suspend fun consumeIfMatchesAccount(
pushWrappedEvent: GiftWrapEvent,
account: Account,
) {
pushWrappedEvent.cachedGift(account.signer) { notificationEvent ->
LocalCache.justConsume(notificationEvent, null)
LocalCache.justConsume(notificationEvent, null)
unwrapAndConsume(notificationEvent, account)?.let { innerEvent ->
if (innerEvent is PrivateDmEvent) {
notify(innerEvent, account)
} else if (innerEvent is LnZapEvent) {
notify(innerEvent, account)
} else if (innerEvent is ChatMessageEvent) {
notify(innerEvent, account)
}
}
}
} else if (key != null) {
pushWrappedEvent.unwrap(key)?.let { notificationEvent ->
LocalCache.justConsume(notificationEvent, null)
unwrapAndConsume(notificationEvent, account)?.let { innerEvent ->
if (innerEvent is PrivateDmEvent) {
notify(innerEvent, account)
} else if (innerEvent is LnZapEvent) {
notify(innerEvent, account)
} else if (innerEvent is ChatMessageEvent) {
notify(innerEvent, account)
}
unwrapAndConsume(notificationEvent, account) { innerEvent ->
if (innerEvent is PrivateDmEvent) {
notify(innerEvent, account)
} else if (innerEvent is LnZapEvent) {
notify(innerEvent, account)
} else if (innerEvent is ChatMessageEvent) {
notify(innerEvent, account)
}
}
}
}
private fun unwrapAndConsume(event: Event, account: Account): Event? {
if (!LocalCache.justVerify(event)) return null
private fun unwrapAndConsume(
event: Event,
account: Account,
onReady: (Event) -> Unit,
) {
if (!LocalCache.justVerify(event)) return
return when (event) {
when (event) {
is GiftWrapEvent -> {
val key = account.keyPair.privKey
if (key != null) {
event.cachedGift(key)?.let {
unwrapAndConsume(it, account)
}
} else if (account.loginWithExternalSigner) {
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
if (cached == null) {
ExternalSignerUtils.decrypt(
event.content,
event.pubKey,
event.id,
SignerType.NIP44_DECRYPT
)
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
}
event.cachedGift(account.keyPair.pubKey, cached)?.let {
unwrapAndConsume(it, account)
}
} else {
null
}
event.cachedGift(account.signer) { unwrapAndConsume(it, account, onReady) }
}
is SealedGossipEvent -> {
val key = account.keyPair.privKey
if (key != null) {
event.cachedGossip(key)?.let {
// this is not verifiable
LocalCache.justConsume(it, null)
it
}
} else if (account.loginWithExternalSigner) {
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
if (cached == null) {
ExternalSignerUtils.decrypt(
event.content,
event.pubKey,
event.id,
SignerType.NIP44_DECRYPT
)
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
}
event.cachedGossip(account.keyPair.pubKey, cached)?.let {
LocalCache.justConsume(it, null)
it
}
} else {
null
event.cachedGossip(account.signer) {
// this is not verifiable
LocalCache.justConsume(it, null)
onReady(it)
}
}
else -> {
LocalCache.justConsume(event, null)
event
onReady(event)
}
}
}
private fun notify(event: ChatMessageEvent, acc: Account) {
if (event.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted
private fun notify(
event: ChatMessageEvent,
acc: Account,
) {
if (
event.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted
event.pubKey != acc.userProfile().pubkeyHex
) { // from the user
@@ -160,9 +117,10 @@ class EventNotificationConsumer(private val applicationContext: Context) {
val followingKeySet = acc.followingKeySet()
val isKnownRoom = (
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(chatRoom)
val isKnownRoom =
(
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(chatRoom)
) && !acc.isAllHidden(chatRoom.users)
if (isKnownRoom) {
@@ -170,90 +128,119 @@ class EventNotificationConsumer(private val applicationContext: Context) {
val user = chatNote.author?.toBestDisplayName() ?: ""
val userPicture = chatNote.author?.profilePicture()
val noteUri = chatNote.toNEvent()
notificationManager().sendDMNotification(
event.id,
content,
user,
userPicture,
noteUri,
applicationContext
)
notificationManager()
.sendDMNotification(
event.id,
content,
user,
userPicture,
noteUri,
applicationContext,
)
}
}
}
private fun notify(event: PrivateDmEvent, acc: Account) {
private fun notify(
event: PrivateDmEvent,
acc: Account,
) {
val note = LocalCache.notes[event.id] ?: return
// old event being re-broadcast
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
if (acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
val followingKeySet = acc.followingKeySet()
val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter {
(
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(it)
) && !acc.isAllHidden(it.users)
}.toSet()
val knownChatrooms =
acc
.userProfile()
.privateChatrooms
.keys
.filter {
(
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(it)
) && !acc.isAllHidden(it.users)
}
.toSet()
note.author?.let {
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
val content = acc.decryptContent(note) ?: ""
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
acc.decryptContent(note) { content ->
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
notificationManager()
.sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
}
}
}
}
}
private fun notify(event: LnZapEvent, acc: Account) {
private fun notify(
event: LnZapEvent,
acc: Account,
) {
val noteZapEvent = LocalCache.notes[event.id] ?: return
// old event being re-broadcast
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) }
val noteZapped =
event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
if (acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
val amount = showAmount(event.amount)
val senderInfo = (noteZapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest)
if (decryptedContent != null) {
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
Pair(author, decryptedContent.content)
} else if (!noteZapRequest.event?.content().isNullOrBlank()) {
Pair(noteZapRequest.author, noteZapRequest.event?.content())
} else {
Pair(noteZapRequest.author, null)
(noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
acc.decryptZapContentAuthor(noteZapRequest) {
val author = LocalCache.getOrCreateUser(it.pubKey)
val senderInfo = Pair(author, it.content.ifBlank { null })
acc.decryptContent(noteZapped) {
val zappedContent = it.split("\n").get(0)
val user = senderInfo.first.toBestDisplayName()
var title =
applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
senderInfo.second?.ifBlank { null }?.let { title += " ($it)" }
var content =
applicationContext.getString(
R.string.app_notification_zaps_channel_message_from,
user,
)
zappedContent?.let {
content +=
" " +
applicationContext.getString(
R.string.app_notification_zaps_channel_message_for,
zappedContent,
)
}
val userPicture = senderInfo?.first?.profilePicture()
val noteUri = "nostr:Notifications"
notificationManager()
.sendZapNotification(
event.id,
content,
title,
userPicture,
noteUri,
applicationContext,
)
}
}
}
val zappedContent =
noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0) }
val user = senderInfo?.first?.toBestDisplayName() ?: ""
var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
senderInfo?.second?.ifBlank { null }?.let {
title += " ($it)"
}
var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)
zappedContent?.let {
content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent)
}
val userPicture = senderInfo?.first?.profilePicture()
val noteUri = "nostr:Notifications"
notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
}
}
fun notificationManager(): NotificationManager {
return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager
return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java)
as NotificationManager
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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.app.NotificationChannel
@@ -24,13 +44,16 @@ object NotificationUtils {
fun NotificationManager.getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
if (dmChannel != null) return dmChannel!!
dmChannel = NotificationChannel(
applicationContext.getString(R.string.app_notification_dms_channel_id),
applicationContext.getString(R.string.app_notification_dms_channel_name),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = applicationContext.getString(R.string.app_notification_dms_channel_description)
}
dmChannel =
NotificationChannel(
applicationContext.getString(R.string.app_notification_dms_channel_id),
applicationContext.getString(R.string.app_notification_dms_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
)
.apply {
description =
applicationContext.getString(R.string.app_notification_dms_channel_description)
}
// Register the channel with the system
val notificationManager: NotificationManager =
@@ -44,13 +67,16 @@ object NotificationUtils {
fun NotificationManager.getOrCreateZapChannel(applicationContext: Context): NotificationChannel {
if (zapChannel != null) return zapChannel!!
zapChannel = NotificationChannel(
applicationContext.getString(R.string.app_notification_zaps_channel_id),
applicationContext.getString(R.string.app_notification_zaps_channel_name),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = applicationContext.getString(R.string.app_notification_zaps_channel_description)
}
zapChannel =
NotificationChannel(
applicationContext.getString(R.string.app_notification_zaps_channel_id),
applicationContext.getString(R.string.app_notification_zaps_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
)
.apply {
description =
applicationContext.getString(R.string.app_notification_zaps_channel_description)
}
// Register the channel with the system
val notificationManager: NotificationManager =
@@ -67,12 +93,21 @@ object NotificationUtils {
messageTitle: String,
pictureUrl: String?,
uri: String,
applicationContext: Context
applicationContext: Context,
) {
val zapChannel = getOrCreateZapChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id)
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, ZAP_GROUP_KEY, applicationContext)
sendNotification(
id,
messageBody,
messageTitle,
pictureUrl,
uri,
channelId,
ZAP_GROUP_KEY,
applicationContext,
)
}
fun NotificationManager.sendDMNotification(
@@ -81,12 +116,21 @@ object NotificationUtils {
messageTitle: String,
pictureUrl: String?,
uri: String,
applicationContext: Context
applicationContext: Context,
) {
val dmChannel = getOrCreateDMChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id)
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, DM_GROUP_KEY, applicationContext)
sendNotification(
id,
messageBody,
messageTitle,
pictureUrl,
uri,
channelId,
DM_GROUP_KEY,
applicationContext,
)
}
fun NotificationManager.sendNotification(
@@ -97,12 +141,10 @@ object NotificationUtils {
uri: String,
channelId: String,
notificationGroupKey: String,
applicationContext: Context
applicationContext: Context,
) {
if (pictureUrl != null) {
val request = ImageRequest.Builder(applicationContext)
.data(pictureUrl)
.build()
val request = ImageRequest.Builder(applicationContext).data(pictureUrl).build()
val imageLoader = ImageLoader(applicationContext)
val imageResult = imageLoader.executeBlocking(request)
@@ -114,7 +156,7 @@ object NotificationUtils {
uri = uri,
channelId,
notificationGroupKey,
applicationContext = applicationContext
applicationContext = applicationContext,
)
} else {
sendNotification(
@@ -125,7 +167,7 @@ object NotificationUtils {
uri = uri,
channelId,
notificationGroupKey,
applicationContext = applicationContext
applicationContext = applicationContext,
)
}
}
@@ -138,7 +180,7 @@ object NotificationUtils {
uri: String,
channelId: String,
notificationGroupKey: String,
applicationContext: Context
applicationContext: Context,
) {
val notId = id.hashCode()
@@ -150,54 +192,56 @@ object NotificationUtils {
}
}
val contentIntent = Intent(applicationContext, MainActivity::class.java).apply {
data = Uri.parse(uri)
}
val contentIntent =
Intent(applicationContext, MainActivity::class.java).apply { data = Uri.parse(uri) }
val contentPendingIntent = PendingIntent.getActivity(
applicationContext,
notId,
contentIntent,
PendingIntent.FLAG_MUTABLE
)
val contentPendingIntent =
PendingIntent.getActivity(
applicationContext,
notId,
contentIntent,
PendingIntent.FLAG_MUTABLE,
)
// Build the notification
val builderPublic = NotificationCompat.Builder(
applicationContext,
channelId
)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(messageTitle)
.setContentText(applicationContext.getString(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)
val builderPublic =
NotificationCompat.Builder(
applicationContext,
channelId,
)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(messageTitle)
.setContentText(applicationContext.getString(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)
// Build the notification
val builder = NotificationCompat.Builder(
applicationContext,
channelId
)
.setSmallIcon(R.drawable.amethyst)
.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)
.setAutoCancel(true)
val builder =
NotificationCompat.Builder(
applicationContext,
channelId,
)
.setSmallIcon(R.drawable.amethyst)
.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)
.setAutoCancel(true)
notify(notId, builder.build())
}
/**
* Cancels all notifications.
*/
/** Cancels all notifications. */
fun NotificationManager.cancelNotifications() {
cancelAll()
}

View File

@@ -1,12 +1,33 @@
/**
* Copyright (c) 2023 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.util.Log
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.quartz.events.RelayAuthEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
@@ -14,38 +35,64 @@ import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
class RegisterAccounts(
private val accounts: List<AccountInfo>
private val accounts: List<AccountInfo>,
) {
private fun recursiveAuthCreation(
notificationToken: String,
remainingTos: List<Pair<Account, String>>,
output: MutableList<RelayAuthEvent>,
onReady: (List<RelayAuthEvent>) -> Unit,
) {
if (remainingTos.isEmpty()) {
onReady(output)
return
}
val next = remainingTos.first()
next.first.createAuthEvent(next.second, notificationToken) {
output.add(it)
recursiveAuthCreation(notificationToken, remainingTos.filter { next != it }, output, onReady)
}
}
// creates proof that it controls all accounts
private suspend fun signEventsToProveControlOfAccounts(
accounts: List<AccountInfo>,
notificationToken: String
): List<RelayAuthEvent> {
return accounts.mapNotNull {
val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)
if (acc != null && (acc.isWriteable() || acc.loginWithExternalSigner)) {
if (acc.loginWithExternalSigner) {
ExternalSignerUtils.account = acc
}
notificationToken: String,
onReady: (List<RelayAuthEvent>) -> Unit,
) {
val readyToSend =
accounts.mapNotNull {
val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)
if (acc != null && acc.isWriteable()) {
val readRelays =
acc.userProfile().latestContactList?.relays() ?: acc.backupContactList?.relays()
val readRelays = acc.userProfile().latestContactList?.relays() ?: acc.backupContactList?.relays()
val relayToUse = readRelays?.firstNotNullOfOrNull { if (it.value.read) it.key else null }
if (relayToUse != null) {
acc.createAuthEvent(relayToUse, notificationToken)
val relayToUse = readRelays?.firstNotNullOfOrNull { if (it.value.read) it.key else null }
if (relayToUse != null) {
Pair(acc, relayToUse)
} else {
null
}
} else {
null
}
} else {
null
}
}
val listOfAuthEvents = mutableListOf<RelayAuthEvent>()
recursiveAuthCreation(
notificationToken,
readyToSend,
listOfAuthEvents,
onReady,
)
}
fun postRegistrationEvent(events: List<RelayAuthEvent>) {
try {
val jsonObject = """{
val jsonObject =
"""{
"events": [ ${events.joinToString(", ") { it.toJson() }} ]
}
"""
@@ -53,31 +100,32 @@ class RegisterAccounts(
val mediaType = "application/json; charset=utf-8".toMediaType()
val body = jsonObject.toRequestBody(mediaType)
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url("https://push.amethyst.social/register")
.post(body)
.build()
val request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url("https://push.amethyst.social/register")
.post(body)
.build()
val client = HttpClient.getHttpClient()
val client = HttpClientManager.getHttpClient()
val isSucess = client.newCall(request).execute().use {
it.isSuccessful
}
val isSucess = client.newCall(request).execute().use { it.isSuccessful }
} catch (e: java.lang.Exception) {
val tag = if (BuildConfig.FLAVOR == "play") {
"FirebaseMsgService"
} else {
"UnifiedPushService"
}
if (e is CancellationException) throw e
val tag =
if (BuildConfig.FLAVOR == "play") {
"FirebaseMsgService"
} else {
"UnifiedPushService"
}
Log.e(tag, "Unable to register with push server", e)
}
}
suspend fun go(notificationToken: String) = withContext(Dispatchers.IO) {
postRegistrationEvent(
signEventsToProveControlOfAccounts(accounts, notificationToken)
)
PushNotificationUtils.hasInit = true
}
suspend fun go(notificationToken: String) =
withContext(Dispatchers.IO) {
signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) }
PushNotificationUtils.hasInit = true
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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.playback
import android.app.PendingIntent
@@ -20,7 +40,7 @@ import kotlin.math.abs
class MultiPlayerPlaybackManager(
private val dataSourceFactory: androidx.media3.exoplayer.source.MediaSource.Factory? = null,
private val cachedPositions: VideoViewedPositionCache
private val cachedPositions: VideoViewedPositionCache,
) {
// protects from LruCache killing playing sessions
private val playingMap = mutableMapOf<String, MediaSession>()
@@ -31,7 +51,7 @@ class MultiPlayerPlaybackManager(
evicted: Boolean,
key: String?,
oldValue: MediaSession?,
newValue: MediaSession?
newValue: MediaSession?,
) {
super.entryRemoved(evicted, key, oldValue, newValue)
@@ -44,24 +64,34 @@ class MultiPlayerPlaybackManager(
}
}
private fun getCallbackIntent(callbackUri: String, applicationContext: Context): PendingIntent {
private fun getCallbackIntent(
callbackUri: String,
applicationContext: Context,
): PendingIntent {
return PendingIntent.getActivity(
applicationContext,
0,
Intent(Intent.ACTION_VIEW, callbackUri.toUri(), applicationContext, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun getMediaSession(id: String, uri: String, callbackUri: String?, context: Context, applicationContext: Context): MediaSession {
fun getMediaSession(
id: String,
uri: String,
callbackUri: String?,
context: Context,
applicationContext: Context,
): MediaSession {
val existingSession = playingMap.get(id) ?: cache.get(id)
if (existingSession != null) return existingSession
val player = ExoPlayer.Builder(context).run {
dataSourceFactory?.let { setMediaSourceFactory(it) }
build()
}
val player =
ExoPlayer.Builder(context).run {
dataSourceFactory?.let { setMediaSourceFactory(it) }
build()
}
player.apply {
repeatMode = Player.REPEAT_MODE_ALL
@@ -69,51 +99,52 @@ class MultiPlayerPlaybackManager(
volume = 0f
}
val mediaSession = MediaSession.Builder(context, player).run {
callbackUri?.let {
setSessionActivity(getCallbackIntent(it, applicationContext))
}
setId(id)
build()
}
player.addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) {
player.setWakeMode(C.WAKE_MODE_NETWORK)
playingMap.put(id, mediaSession)
} else {
player.setWakeMode(C.WAKE_MODE_NONE)
cachedPositions.add(uri, player.currentPosition)
cache.put(id, mediaSession)
playingMap.remove(id, mediaSession)
}
val mediaSession =
MediaSession.Builder(context, player).run {
callbackUri?.let { setSessionActivity(getCallbackIntent(it, applicationContext)) }
setId(id)
build()
}
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
STATE_IDLE -> {
// only saves if it wqs playing
if (abs(player.currentPosition) > 1) {
cachedPositions.add(uri, player.currentPosition)
}
player.addListener(
object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) {
player.setWakeMode(C.WAKE_MODE_NETWORK)
playingMap.put(id, mediaSession)
} else {
player.setWakeMode(C.WAKE_MODE_NONE)
cachedPositions.add(uri, player.currentPosition)
cache.put(id, mediaSession)
playingMap.remove(id, mediaSession)
}
STATE_READY -> {
cachedPositions.get(uri)?.let { lastPosition ->
if (abs(player.currentPosition - lastPosition) > 5 * 60) {
player.seekTo(lastPosition)
}
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
STATE_IDLE -> {
// only saves if it wqs playing
if (abs(player.currentPosition) > 1) {
cachedPositions.add(uri, player.currentPosition)
}
}
STATE_READY -> {
cachedPositions.get(uri)?.let { lastPosition ->
if (abs(player.currentPosition - lastPosition) > 5 * 60) {
player.seekTo(lastPosition)
}
}
}
else -> {
// only saves if it wqs playing
if (abs(player.currentPosition) > 1) {
cachedPositions.add(uri, player.currentPosition)
}
}
}
else -> {
// only saves if it wqs playing
if (abs(player.currentPosition) > 1) {
cachedPositions.add(uri, player.currentPosition)
}
}
}
}
})
},
)
cache.put(id, mediaSession)

View File

@@ -1,21 +1,45 @@
/**
* Copyright (c) 2023 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.playback
import android.content.ComponentName
import android.content.Context
import android.os.Bundle
import android.util.Log
import android.util.LruCache
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.MoreExecutors
import kotlinx.coroutines.CancellationException
object PlaybackClientController {
val cache = LruCache<Int, SessionToken>(1)
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun prepareController(
controllerID: String,
videoUri: String,
callbackUri: String?,
context: Context,
onReady: (MediaController) -> Unit
onReady: (MediaController) -> Unit,
) {
try {
// creating a bundle object
@@ -25,23 +49,28 @@ object PlaybackClientController {
bundle.putString("uri", videoUri)
bundle.putString("callbackUri", callbackUri)
val sessionTokenLocal = SessionToken(context, ComponentName(context, PlaybackService::class.java))
val controllerFuture = MediaController
.Builder(context, sessionTokenLocal)
.setConnectionHints(bundle)
.buildAsync()
var session = cache.get(context.hashCode())
if (session == null) {
session = SessionToken(context, ComponentName(context, PlaybackService::class.java))
cache.put(context.hashCode(), session)
}
val controllerFuture =
MediaController.Builder(context, session).setConnectionHints(bundle).buildAsync()
controllerFuture.addListener(
{
try {
onReady(controllerFuture.get())
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
}
},
MoreExecutors.directExecutor()
MoreExecutors.directExecutor(),
)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
}
}

View File

@@ -1,5 +1,27 @@
/**
* Copyright (c) 2023 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.playback
import android.content.Intent
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
@@ -9,7 +31,7 @@ import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.HttpClientManager
@UnstableApi // Extend MediaSessionService
class PlaybackService : MediaSessionService() {
@@ -20,17 +42,19 @@ class PlaybackService : MediaSessionService() {
private var managerLocal: MultiPlayerPlaybackManager? = null
fun newHslDataSource(): MediaSource.Factory {
return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClient.getHttpClient()))
return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClientManager.getHttpClient()))
}
fun newProgressiveDataSource(): MediaSource.Factory {
return ProgressiveMediaSource.Factory(
(applicationContext as Amethyst).videoCache.get(HttpClient.getHttpClient())
(applicationContext as Amethyst).videoCache.get(HttpClientManager.getHttpClient()),
)
}
fun lazyHlsDS(): MultiPlayerPlaybackManager {
managerHls?.let { return it }
managerHls?.let {
return it
}
val newInstance = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerHls = newInstance
@@ -38,15 +62,20 @@ class PlaybackService : MediaSessionService() {
}
fun lazyProgressiveDS(): MultiPlayerPlaybackManager {
managerProgressive?.let { return it }
managerProgressive?.let {
return it
}
val newInstance = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
val newInstance =
MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
managerProgressive = newInstance
return newInstance
}
fun lazyLocalDS(): MultiPlayerPlaybackManager {
managerLocal?.let { return it }
managerLocal?.let {
return it
}
val newInstance = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache)
managerLocal = newInstance
@@ -58,8 +87,10 @@ class PlaybackService : MediaSessionService() {
override fun onCreate() {
super.onCreate()
Log.d("Lifetime Event", "PlaybackService.onCreate")
// Stop all videos and recreates all managers when the proxy changes.
HttpClient.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
HttpClientManager.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
}
private fun onProxyUpdated() {
@@ -67,14 +98,23 @@ class PlaybackService : MediaSessionService() {
val toDestroyProgressive = managerProgressive
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerProgressive = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
managerProgressive =
MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
toDestroyHls?.releaseAppPlayers()
toDestroyProgressive?.releaseAppPlayers()
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
Log.d("Lifetime Event", "onTaskRemoved")
}
override fun onDestroy() {
HttpClient.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
Log.d("Lifetime Event", "PlaybackService.onDestroy")
HttpClientManager.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
managerHls?.releaseAppPlayers()
managerLocal?.releaseAppPlayers()
@@ -93,7 +133,10 @@ class PlaybackService : MediaSessionService() {
}
}
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
override fun onUpdateNotification(
session: MediaSession,
startInForegroundRequired: Boolean,
) {
// Updates any new player ready
super.onUpdateNotification(session, startInForegroundRequired)
@@ -141,6 +184,12 @@ class PlaybackService : MediaSessionService() {
val manager = getAppropriateMediaSessionManager(uri)
return manager?.getMediaSession(id, uri, callbackUri, context = this, applicationContext = applicationContext)
return manager?.getMediaSession(
id,
uri,
callbackUri,
context = this,
applicationContext = applicationContext,
)
}
}

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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.playback
import android.annotation.SuppressLint
@@ -12,7 +32,6 @@ import java.io.File
@SuppressLint("UnsafeOptInUsageError")
class VideoCache {
var exoPlayerCacheSize: Long = 150 * 1024 * 1024 // 90MB
var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize)
@@ -26,21 +45,23 @@ class VideoCache {
fun initFileCache(context: Context) {
exoDatabaseProvider = StandaloneDatabaseProvider(context)
simpleCache = SimpleCache(
File(context.cacheDir, "exoplayer"),
leastRecentlyUsedCacheEvictor,
exoDatabaseProvider
)
simpleCache =
SimpleCache(
File(context.cacheDir, "exoplayer"),
leastRecentlyUsedCacheEvictor,
exoDatabaseProvider,
)
}
// This method should be called when proxy setting changes.
fun renewCacheFactory(client: OkHttpClient) {
cacheDataSourceFactory = CacheDataSource.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(
OkHttpDataSource.Factory(client)
)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
cacheDataSourceFactory =
CacheDataSource.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(
OkHttpDataSource.Factory(client),
)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
fun get(client: OkHttpClient): CacheDataSource.Factory {

View File

@@ -1,3 +1,23 @@
/**
* Copyright (c) 2023 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.playback
import android.util.LruCache
@@ -5,7 +25,10 @@ import android.util.LruCache
class VideoViewedPositionCache {
val cachedPosition = LruCache<String, Long>(100)
fun add(uri: String, position: Long) {
fun add(
uri: String,
position: Long,
) {
cachedPosition.put(uri, position)
}

View File

@@ -1,42 +1,42 @@
/**
* Copyright (c) 2023 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 android.net.Uri
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.Locale
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
private val imageExtensionArray = arrayOf(".gif", ".png", ".jpg", ".jpeg", ".bmp", ".webp")
suspend fun fetchUrlPreview(timeOut: Int = 30000) = withContext(Dispatchers.IO) {
try {
fetch(timeOut)
} catch (t: Throwable) {
callback?.onFailed(t)
}
}
private suspend fun fetch(timeOut: Int = 30000) {
lateinit var urlInfoItem: UrlInfoItem
if (checkIsImageUrl()) {
urlInfoItem = UrlInfoItem(url = url, image = url)
} else {
val document = getDocument(url, timeOut)
urlInfoItem = parseHtml(url, document)
}
callback?.onComplete(urlInfoItem)
}
private fun checkIsImageUrl(): Boolean {
val uri = Uri.parse(url)
var isImage = false
for (imageExtension in imageExtensionArray) {
if (uri.path != null && uri.path!!.toLowerCase(Locale.getDefault()).endsWith(imageExtension)) {
isImage = true
break
suspend fun fetchUrlPreview(timeOut: Int = 30000) =
withContext(Dispatchers.IO) {
try {
fetch(timeOut)
} catch (t: Throwable) {
if (t is CancellationException) throw t
callback?.onFailed(t)
}
}
return isImage
private suspend fun fetch(timeOut: Int = 30000) {
callback?.onComplete(getDocument(url, timeOut))
}
fun cleanUp() {

View File

@@ -1,6 +1,27 @@
/**
* Copyright (c) 2023 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
interface IUrlPreviewCallback {
suspend fun onComplete(urlInfo: UrlInfoItem)
suspend fun onFailed(throwable: Throwable)
}

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