Compare commits

...

138 Commits

Author SHA1 Message Date
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
464 changed files with 45245 additions and 29261 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

141
README.md
View File

@@ -18,87 +18,88 @@ 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)
- [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] 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] 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] Gift Wraps & Seals (NIP-59/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:30402)
- [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
@@ -149,8 +150,8 @@ Build the app:
## Linting
```bash
./gradlew ktlintCheck
./gradlew ktlintFormat
./gradlew spotlessCheck
./gradlew spotlessApply
```
## Installing on device

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,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 340
versionName "0.82.3"
versionCode 348
versionName "0.83.7"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -89,7 +88,6 @@ android {
buildTypes {
release {
//signingConfig signingConfigs.debug
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), 'proguard-rules.pro'
minifyEnabled true
resValue "string", "app_name", "@string/app_name_release"
@@ -99,6 +97,10 @@ android {
versionNameSuffix '-DEBUG'
resValue "string", "app_name", "@string/app_name_debug"
}
create("benchmark") {
initWith(getByName("release"))
signingConfig signingConfigs.debug
}
}
flavorDimensions = ["channel"]
@@ -134,6 +136,7 @@ android {
buildFeatures {
compose true
buildConfig true
}
composeOptions {
@@ -158,7 +161,7 @@ android {
dependencies {
implementation project(path: ':quartz')
implementation "androidx.core:core-ktx:$core_ktx_version"
implementation 'androidx.activity:activity-compose:1.8.1'
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"
@@ -187,16 +190,16 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// Zoomable images
implementation 'net.engawapg.lib:zoomable:1.5.2'
implementation 'net.engawapg.lib:zoomable:1.5.3'
// 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.17.1'
implementation 'org.jsoup:jsoup:1.17.2'
// Encrypted Key Storage
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
@@ -247,7 +250,7 @@ dependencies {
playImplementation 'com.google.mlkit:translate:17.0.2'
// PushNotifications
playImplementation platform('com.google.firebase:firebase-bom:32.6.0')
playImplementation platform('com.google.firebase:firebase-bom:32.7.0')
playImplementation 'com.google.firebase:firebase-messaging-ktx'
//PushNotifications(FDroid)
@@ -271,7 +274,7 @@ dependencies {
implementation 'id.zelory:compressor:3.0.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.8'
testImplementation 'io.mockk:mockk:1.13.9'
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha02'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.0-alpha02'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'

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
@@ -5,8 +25,8 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.Nip96Retriever
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.amethyst.ui.actions.Nip96Uploader
import com.vitorpamplona.quartz.crypto.KeyPair
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue
@@ -20,45 +40,98 @@ import java.util.Base64
@RunWith(AndroidJUnit4::class)
class ImageUploadTesting {
val contentType = "image/gif"
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 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 contentTypePng = "image/png"
val imagePng = "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII="
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 suspend fun testBase(server: Nip96MediaServers.ServerName) {
val serverInfo = Nip96Retriever().loadInfo(
server.baseUrl
)
val serverInfo =
Nip96Retriever()
.loadInfo(
server.baseUrl,
)
val bytes = Base64.getDecoder().decode(imagePng)
val inputStream = bytes.inputStream()
val account = Account(KeyPair())
val result = Nip96Uploader(account).uploadImage(
inputStream,
bytes.size.toLong(),
contentTypePng,
alt = null,
sensitiveContent = null,
serverInfo,
onProgress = {
}
)
val result =
Nip96Uploader(account)
.uploadImage(
inputStream,
bytes.size.toLong(),
contentTypePng,
alt = null,
sensitiveContent = null,
serverInfo,
onProgress = {},
)
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)
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
}
val imageData: ByteArray =
ImageDownloader().waitAndGetImage(url)
?: run {
fail("Should not be null")
return
}
FileHeader.prepare(
imageData,
@@ -75,9 +148,7 @@ class ImageUploadTesting {
assertEquals(hash, it.hash)
}
},
onError = {
fail("It should not fail")
}
onError = { fail("It should not fail") },
)
// delay(1000)
@@ -86,32 +157,38 @@ class ImageUploadTesting {
}
@Test()
fun testNostrCheck() = runBlocking {
testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me"))
}
fun testNostrCheck() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me"))
}
@Test()
fun testNostrage() = runBlocking {
testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com"))
}
fun testNostrage() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com"))
}
@Test()
fun testSove() = runBlocking {
testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent"))
}
fun testSove() =
runBlocking {
testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent"))
}
@Test()
fun testNostrBuild() = runBlocking {
testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build"))
}
fun testNostrBuild() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build"))
}
@Test()
fun testSovbit() = runBlocking {
testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host"))
}
fun testSovbit() =
runBlocking {
testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host"))
}
@Test()
fun testVoidCat() = runBlocking {
testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat"))
}
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
@@ -20,20 +40,25 @@ 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) {
Log.d(TAG, "Message could not be parsed: ${e.message}")
}
@@ -54,7 +79,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 +94,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 +125,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,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.util.Log
@@ -6,6 +26,7 @@ import com.vitorpamplona.amethyst.AccountInfo
object PushNotificationUtils {
var hasInit: Boolean = false
private val pushHandler = PushDistributorHandler
suspend fun init(accounts: List<AccountInfo>) {
if (hasInit || pushHandler.savedDistributorExists()) {
return

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

@@ -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,85 +1,100 @@
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 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 }
}
companion object {
lateinit var instance: Amethyst
private set
}
}
internal val Context.safeCacheDir: File
get() {
val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" }
return cacheDir.apply { mkdirs() }
}
/**
* 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 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 }
}
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,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.security.crypto.EncryptedSharedPreferences
@@ -13,9 +33,10 @@ object EncryptedStorage {
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()
val masterKey: MasterKey =
MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val preferencesName = prefsFileName(npub)
@@ -24,7 +45,7 @@ object EncryptedStorage {
preferencesName,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
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
@@ -51,7 +71,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 {
@@ -97,65 +117,71 @@ private object PrefKeys {
}
object LocalPreferences {
private const val comma = ","
private const val COMMA = ","
private var _currentAccount: String? = null
private var _savedAccounts: List<AccountInfo>? = null
private var _cachedAccounts: MutableMap<String, Account?> = mutableMapOf()
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/"
@@ -165,32 +191,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.signer is NostrSignerExternal
)
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()
@@ -206,7 +228,8 @@ 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)
@@ -214,26 +237,27 @@ object LocalPreferences {
}
/**
* 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)
@@ -244,54 +268,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.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))
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? {
return currentAccount()?.let {
loadCurrentAccountFromEncryptedStorage(it)
}
return currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
}
suspend fun migrateOldSharedSettings(): Settings? {
@@ -303,7 +353,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()
@@ -313,11 +366,13 @@ 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)
Log.w(
"LocalPreferences",
"Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}",
e,
)
e.printStackTrace()
null
}
@@ -331,39 +386,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,
@@ -374,173 +435,219 @@ object LocalPreferences {
automaticallyHideNavigationBars,
automaticallyShowProfilePictures,
false,
false
false,
)
}
}
val mutex = Mutex()
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): Account? = withContext(Dispatchers.IO) {
mutex.withLock {
if (_cachedAccounts.containsKey(npub)) {
return@withContext _cachedAccounts.get(npub)
}
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) {
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)
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) {
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) {
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) {
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) {
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()
}
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()
}
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()
}
return@with account
}
}
}

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,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.os.Build
@@ -42,7 +62,8 @@ import kotlinx.coroutines.launch
@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 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())
@@ -64,16 +85,18 @@ class ServiceManager {
// Resets Proxy Use
HttpClient.start(account?.proxy)
OptOutFromFilters.start(account?.warnAboutPostsWithReports ?: true, account?.filterSpamFromStrangers ?: true)
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())
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() }
.precision(Precision.INEXACT)
.respectCacheHeaders(false)
@@ -87,24 +110,26 @@ class ServiceManager {
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)
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) {
null
NostrAccountDataSource.otherAccounts =
LocalPreferences.allSavedAccounts().mapNotNull {
try {
it.npub.bechToBytes().toHexKey()
} catch (e: Exception) {
null
}
}
}
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrVideoDataSource.account = myAccount
@@ -163,9 +188,8 @@ class 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)
@@ -182,7 +206,11 @@ class 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) {
fun forceRestart(
account: Account? = null,
start: Boolean = true,
pause: Boolean = true,
) {
if (pause) {
pause()
}

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,7 +39,11 @@ 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) {
fun updateChannelInfo(
creator: User,
channelInfo: ChannelCreateEvent.ChannelData,
updatedAt: Long,
) {
this.info = channelInfo
super.updateChannelInfo(creator, updatedAt)
}
@@ -38,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()
}
}
@@ -48,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)
}
@@ -70,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()
}
}
@@ -83,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 {
@@ -102,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
@@ -127,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
}
@@ -151,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

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
@@ -42,6 +62,7 @@ import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.WrappedEvent
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import java.math.BigDecimal
@@ -52,9 +73,13 @@ import java.time.format.DateTimeFormatter
@Stable
class AddressableNote(val address: ATag) : Note(address.toTag()) {
override fun idNote() = address.toNAddr()
override fun toNEvent() = address.toNAddr()
override fun idDisplayNote() = idNote().toShortenHex()
override fun address() = address
override fun createdAt(): Long? {
if (event == null) return null
@@ -80,14 +105,19 @@ open class Note(val idHex: String) {
// These fields are updated every time an event related to this note is received.
var replies = listOf<Note>()
private set
var reactions = mapOf<String, List<Note>>()
private set
var boosts = listOf<Note>()
private set
var reports = mapOf<User, List<Note>>()
private set
var zaps = mapOf<Note, Note?>()
private set
var zapsAmount: BigDecimal = BigDecimal.ZERO
var zapPayments = mapOf<Note, Note?>()
@@ -99,6 +129,7 @@ open class Note(val idHex: String) {
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
fun id() = Hex.decode(idHex)
open fun idNote() = id().toNote()
open fun toNEvent(): String {
@@ -110,7 +141,7 @@ open class Note(val idHex: String) {
host.id,
host.pubKey,
host.kind(),
relays.firstOrNull()?.url
relays.firstOrNull()?.url,
)
} else {
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
@@ -127,15 +158,15 @@ open class Note(val idHex: String) {
open fun idDisplayNote() = idNote().toShortenHex()
fun channelHex(): HexKey? {
return if (event is ChannelMessageEvent ||
return if (
event is ChannelMessageEvent ||
event is ChannelMetadataEvent ||
event is ChannelCreateEvent ||
event is LiveActivitiesChatMessageEvent ||
event is LiveActivitiesEvent
) {
(event as? ChannelMessageEvent)?.channel()
?: (event as? ChannelMetadataEvent)?.channel()
?: (event as? ChannelCreateEvent)?.id
?: (event as? ChannelMetadataEvent)?.channel() ?: (event as? ChannelCreateEvent)?.id
?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag()
?: (event as? LiveActivitiesEvent)?.address()?.toTag()
} else {
@@ -147,7 +178,11 @@ open class Note(val idHex: String) {
open fun createdAt() = event?.createdAt()
fun loadEvent(event: Event, author: User, replyTo: List<Note>) {
fun loadEvent(
event: Event,
author: User,
replyTo: List<Note>,
) {
if (this.event?.id() != event.id()) {
this.event = event
this.author = author
@@ -159,7 +194,8 @@ open class Note(val idHex: String) {
}
fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
return Instant.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss"))
}
@@ -173,50 +209,62 @@ open class Note(val idHex: String) {
cachedSignatures: MutableMap<Note, LevelSignature>,
account: User,
accountFollowingSet: Set<String>,
now: Long
now: Long,
): LevelSignature {
val replyTo = replyTo
if (event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()) {
if (
event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
return LevelSignature(
signature = "/" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) + ";",
createdAt = createdAt(),
author = author
author = author,
)
}
val parent = (
replyTo
.filter { it.idHex in eventsToConsider } // This forces the signature to be based on a branch, avoiding two roots
.map {
cachedSignatures[it] ?: it.replyLevelSignature(
eventsToConsider,
cachedSignatures,
account,
accountFollowingSet,
now
).apply { cachedSignatures.put(it, this) }
}
.maxByOrNull { it.signature.length }
val parent =
(
replyTo
.filter {
it.idHex in eventsToConsider
} // This forces the signature to be based on a branch, avoiding two roots
.map {
cachedSignatures[it]
?: it
.replyLevelSignature(
eventsToConsider,
cachedSignatures,
account,
accountFollowingSet,
now,
)
.apply { cachedSignatures.put(it, this) }
}
.maxByOrNull { it.signature.length }
)
val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
val threadOrder = if (parent?.author == author && createdAt() != null) {
// author of the thread first, in **ascending** order
"9" + formattedDateTime((parent?.createdAt ?: 0) + (now - (createdAt() ?: 0))) + idHex.substring(0, 8)
} else if (author?.pubkeyHex == account.pubkeyHex) {
"8" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my replies
} else if (author?.pubkeyHex in accountFollowingSet) {
"7" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my follows replies.
} else {
"0" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // everyone else.
}
val threadOrder =
if (parent?.author == author && createdAt() != null) {
// author of the thread first, in **ascending** order
"9" +
formattedDateTime((parent?.createdAt ?: 0) + (now - (createdAt() ?: 0))) +
idHex.substring(0, 8)
} else if (author?.pubkeyHex == account.pubkeyHex) {
"8" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my replies
} else if (author?.pubkeyHex in accountFollowingSet) {
"7" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my follows replies.
} else {
"0" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // everyone else.
}
val mySignature = LevelSignature(
signature = parentSignature + "/" + threadOrder + ";",
createdAt = createdAt(),
author = author
)
val mySignature =
LevelSignature(
signature = parentSignature + "/" + threadOrder + ";",
createdAt = createdAt(),
author = author,
)
cachedSignatures[this] = mySignature
return mySignature
@@ -224,7 +272,9 @@ open class Note(val idHex: String) {
fun replyLevel(cachedLevels: MutableMap<Note, Int> = mutableMapOf()): Int {
val replyTo = replyTo
if (event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()) {
if (
event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
return 0
}
@@ -255,14 +305,15 @@ open class Note(val idHex: String) {
}
fun removeAllChildNotes(): List<Note> {
val toBeRemoved = replies +
reactions.values.flatten() +
boosts +
reports.values.flatten() +
zaps.keys +
zaps.values.filterNotNull() +
zapPayments.keys +
zapPayments.values.filterNotNull()
val toBeRemoved =
replies +
reactions.values.flatten() +
boosts +
reports.values.flatten() +
zaps.keys +
zaps.values.filterNotNull() +
zapPayments.keys +
zapPayments.values.filterNotNull()
replies = listOf<Note>()
reactions = mapOf<String, List<Note>>()
@@ -287,7 +338,7 @@ open class Note(val idHex: String) {
val tags = note.event?.tags() ?: emptyArray()
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
if (reaction in reactions.keys && reactions[reaction]?.contains(note) == true) {
if (reactions[reaction]?.contains(note) == true) {
reactions[reaction]?.let {
if (note in it) {
val newList = it.minus(note)
@@ -306,7 +357,7 @@ open class Note(val idHex: 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()
@@ -327,12 +378,11 @@ open class Note(val idHex: String) {
}
fun removeZapPayment(note: Note) {
if (zapPayments[note] != null) {
if (zapPayments.containsKey(note)) {
zapPayments = zapPayments.minus(note)
liveSet?.innerZaps?.invalidateData()
} else if (zapPayments.containsValue(note)) {
val toRemove = zapPayments.filterValues { it == note }
zapPayments = zapPayments.minus(toRemove.keys)
zapPayments = zapPayments.filterValues { it != note }
liveSet?.innerZaps?.invalidateData()
}
}
@@ -345,11 +395,11 @@ open class Note(val idHex: String) {
}
@Synchronized
private fun innerAddZap(zapRequest: Note, zap: Note?): Boolean {
if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap)
return true
} else if (zaps[zapRequest] == null) {
private fun innerAddZap(
zapRequest: Note,
zap: Note?,
): Boolean {
if (zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
return true
}
@@ -357,15 +407,13 @@ open class Note(val idHex: String) {
return false
}
fun addZap(zapRequest: Note, zap: Note?) {
fun addZap(
zapRequest: Note,
zap: Note?,
) {
checkNotInMainThread()
if (zapRequest !in zaps.keys) {
val inserted = innerAddZap(zapRequest, zap)
if (inserted) {
updateZapTotal()
liveSet?.innerZaps?.invalidateData()
}
} else if (zaps[zapRequest] == null) {
if (zaps[zapRequest] == null) {
val inserted = innerAddZap(zapRequest, zap)
if (inserted) {
updateZapTotal()
@@ -375,11 +423,11 @@ open class Note(val idHex: String) {
}
@Synchronized
private fun innerAddZapPayment(zapPaymentRequest: Note, zapPayment: Note?): Boolean {
if (zapPaymentRequest !in zapPayments.keys) {
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
return true
} else if (zapPayments[zapPaymentRequest] == null) {
private fun innerAddZapPayment(
zapPaymentRequest: Note,
zapPayment: Note?,
): Boolean {
if (zapPayments[zapPaymentRequest] == null) {
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
return true
}
@@ -387,14 +435,12 @@ open class Note(val idHex: String) {
return false
}
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
fun addZapPayment(
zapPaymentRequest: Note,
zapPayment: Note?,
) {
checkNotInMainThread()
if (zapPaymentRequest !in zapPayments.keys) {
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
if (inserted) {
liveSet?.innerZaps?.invalidateData()
}
} else if (zapPayments[zapPaymentRequest] == null) {
if (zapPayments[zapPaymentRequest] == null) {
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
if (inserted) {
liveSet?.innerZaps?.invalidateData()
@@ -406,11 +452,12 @@ open class Note(val idHex: String) {
val tags = note.event?.tags() ?: emptyArray()
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
if (reaction !in reactions.keys) {
val listOfAuthors = reactions[reaction]
if (listOfAuthors == null) {
reactions = reactions + Pair(reaction, listOf(note))
liveSet?.innerReactions?.invalidateData()
} else if (reactions[reaction]?.contains(note) == false) {
reactions = reactions + Pair(reaction, (reactions[reaction] ?: emptySet()) + note)
} else if (!listOfAuthors.contains(note)) {
reactions = reactions + Pair(reaction, listOfAuthors + note)
liveSet?.innerReactions?.invalidateData()
}
}
@@ -418,11 +465,13 @@ open class Note(val idHex: String) {
fun addReport(note: Note) {
val author = note.author ?: return
if (author !in reports.keys) {
val reportsByAuthor = reports[author]
if (reportsByAuthor == null) {
reports = reports + Pair(author, listOf(note))
liveSet?.innerReports?.invalidateData()
} else if (reports[author]?.contains(note) == false) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
} else if (!reportsByAuthor.contains(note)) {
reports = reports + Pair(author, reportsByAuthor + note)
liveSet?.innerReports?.invalidateData()
}
}
@@ -444,7 +493,7 @@ open class Note(val idHex: String) {
private fun recursiveIsPaidByCalculation(
account: Account,
remainingZapPayments: List<Pair<Note, Note?>>,
onWasZappedByAuthor: () -> Unit
onWasZappedByAuthor: () -> Unit,
) {
if (remainingZapPayments.isEmpty()) {
return
@@ -455,13 +504,16 @@ open class Note(val idHex: String) {
val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent
if (zapResponseEvent != null) {
account.decryptZapPaymentResponseEvent(zapResponseEvent) { response ->
if (response is PayInvoiceSuccessResponse && account.isNIP47Author(zapResponseEvent.requestAuthor())) {
if (
response is PayInvoiceSuccessResponse &&
account.isNIP47Author(zapResponseEvent.requestAuthor())
) {
onWasZappedByAuthor()
} else {
recursiveIsPaidByCalculation(
account,
remainingZapPayments.minus(next),
onWasZappedByAuthor
onWasZappedByAuthor,
)
}
}
@@ -473,7 +525,7 @@ open class Note(val idHex: String) {
user: User,
account: Account,
remainingZapEvents: List<Pair<Note, Note?>>,
onWasZappedByAuthor: () -> Unit
onWasZappedByAuthor: () -> Unit,
) {
if (remainingZapEvents.isEmpty()) {
return
@@ -485,23 +537,41 @@ open class Note(val idHex: String) {
onWasZappedByAuthor()
} else {
account.decryptZapContentAuthor(next.first) {
if (it.pubKey == user.pubkeyHex && (option == null || option == (it as? LnZapEvent)?.zappedPollOption())) {
if (
it.pubKey == user.pubkeyHex &&
(option == null || option == (it as? LnZapEvent)?.zappedPollOption())
) {
onWasZappedByAuthor()
} else {
recursiveIsZappedByCalculation(option, user, account, remainingZapEvents.minus(next), onWasZappedByAuthor)
recursiveIsZappedByCalculation(
option,
user,
account,
remainingZapEvents.minus(next),
onWasZappedByAuthor,
)
}
}
}
}
fun isZappedBy(user: User, account: Account, onWasZappedByAuthor: () -> Unit) {
fun isZappedBy(
user: User,
account: Account,
onWasZappedByAuthor: () -> Unit,
) {
recursiveIsZappedByCalculation(null, user, account, zaps.toList(), onWasZappedByAuthor)
if (account.userProfile() == user) {
recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor)
}
}
fun isZappedBy(option: Int?, user: User, account: Account, onWasZappedByAuthor: () -> Unit) {
fun isZappedBy(
option: Int?,
user: User,
account: Account,
onWasZappedByAuthor: () -> Unit,
) {
recursiveIsZappedByCalculation(option, user, account, zaps.toList(), onWasZappedByAuthor)
}
@@ -523,26 +593,28 @@ open class Note(val idHex: String) {
return reports[user]?.isNotEmpty() ?: false
}
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()
}
private fun updateZapTotal() {
var sumOfAmounts = BigDecimal.ZERO
// Regular Zap Receipts
zaps.values.forEach {
val noteEvent = it?.event
zaps.forEach {
val noteEvent = it?.value?.event
if (noteEvent is LnZapEvent) {
sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO
}
@@ -556,7 +628,7 @@ open class Note(val idHex: String) {
remainingZapPayments: List<Pair<Note, Note?>>,
signer: NostrSigner,
output: BigDecimal,
onReady: (BigDecimal) -> Unit
onReady: (BigDecimal) -> Unit,
) {
if (remainingZapPayments.isEmpty()) {
onReady(output)
@@ -568,11 +640,12 @@ open class Note(val idHex: String) {
(next.second?.event as? LnZapPaymentResponseEvent)?.response(signer) { noteEvent ->
if (noteEvent is PayInvoiceSuccessResponse) {
(next.first.event as? LnZapPaymentRequestEvent)?.lnInvoice(signer) { invoice ->
val amount = try {
LnInvoiceUtil.getAmountInSats(invoice)
} catch (e: java.lang.Exception) {
null
}
val amount =
try {
LnInvoiceUtil.getAmountInSats(invoice)
} catch (e: java.lang.Exception) {
null
}
var newAmount = output
@@ -586,31 +659,30 @@ open class Note(val idHex: String) {
remainingZapPayments.minus(next),
signer,
newAmount,
onReady
onReady,
)
}
}
}
}
fun zappedAmountWithNWCPayments(signer: NostrSigner, onReady: (BigDecimal) -> Unit) {
fun zappedAmountWithNWCPayments(
signer: NostrSigner,
onReady: (BigDecimal) -> Unit,
) {
if (zapPayments.isEmpty()) {
onReady(zapsAmount)
}
val invoiceSet = LinkedHashSet<String>(zaps.size + zapPayments.size)
zaps.forEach {
(it.value?.event as? LnZapEvent)?.lnInvoice()?.let {
invoiceSet.add(it)
}
}
zaps.forEach { (it.value?.event as? LnZapEvent)?.lnInvoice()?.let { invoiceSet.add(it) } }
recursiveZappedAmountCalculation(
invoiceSet,
zapPayments.toList(),
signer,
zapsAmount,
onReady
onReady,
)
}
@@ -618,12 +690,13 @@ open class Note(val idHex: String) {
return replies
.filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
.any {
val pledgeValue = try {
BigDecimal(it.event?.content())
} catch (e: Exception) {
null
// do nothing if it can't convert to bigdecimal
}
val pledgeValue =
try {
BigDecimal(it.event?.content())
} catch (e: Exception) {
null
// do nothing if it can't convert to bigdecimal
}
pledgeValue != null && it.author == user
}
@@ -647,10 +720,9 @@ open class Note(val idHex: String) {
val dayAgo = TimeUtils.oneDayAgo()
return reports.isNotEmpty() ||
(
author?.reports?.values?.any {
it.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null
} ?: false
)
author?.reports?.any { it.value.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null }
?: false
)
}
fun isNewThread(): Boolean {
@@ -659,7 +731,7 @@ open class Note(val idHex: String) {
event is GenericRepostEvent ||
replyTo == null ||
replyTo?.size == 0
) &&
) &&
event !is ChannelMessageEvent &&
event !is LiveActivitiesChatMessageEvent
}
@@ -668,11 +740,17 @@ open class Note(val idHex: String) {
return zaps.any { it.key.author == loggedIn }
}
fun hasReacted(loggedIn: User, content: String): Boolean {
fun hasReacted(
loggedIn: User,
content: String,
): Boolean {
return reactedBy(loggedIn, content).isNotEmpty()
}
fun reactedBy(loggedIn: User, content: String): List<Note> {
fun reactedBy(
loggedIn: User,
content: String,
): List<Note> {
return reactions[content]?.filter { it.author == loggedIn } ?: emptyList()
}
@@ -681,7 +759,9 @@ open class Note(val idHex: String) {
}
fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean {
return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > TimeUtils.fiveMinutesAgo() } != null // 5 minute protection
return boosts.firstOrNull {
it.author == loggedIn && (it.createdAt() ?: 0) > TimeUtils.fiveMinutesAgo()
} != null // 5 minute protection
}
fun boostedBy(loggedIn: User): List<Note> {
@@ -732,22 +812,29 @@ open class Note(val idHex: String) {
fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean {
val thisEvent = event ?: return false
val isBoostedNoteHidden = if (thisEvent is GenericRepostEvent || thisEvent is RepostEvent || thisEvent is CommunityPostApprovalEvent) {
replyTo?.lastOrNull()?.isHiddenFor(accountChoices) ?: false
} else {
false
}
val isHiddenByWord = if (thisEvent is BaseTextNoteEvent) {
accountChoices.hiddenWords.any {
thisEvent.content.contains(it, true)
val isBoostedNoteHidden =
if (
thisEvent is GenericRepostEvent ||
thisEvent is RepostEvent ||
thisEvent is CommunityPostApprovalEvent
) {
replyTo?.lastOrNull()?.isHiddenFor(accountChoices) ?: false
} else {
false
}
val isHiddenByWord =
if (thisEvent is BaseTextNoteEvent) {
accountChoices.hiddenWords.any {
thisEvent.content.containsAny(accountChoices.hiddenWordsCase)
}
} else {
false
}
} else {
false
}
val isSensitive = thisEvent.isSensitive()
return isBoostedNoteHidden || isHiddenByWord ||
return isBoostedNoteHidden ||
isHiddenByWord ||
accountChoices.hiddenUsers.contains(author?.pubkeyHex) ||
accountChoices.spammers.contains(author?.pubkeyHex) ||
(isSensitive && accountChoices.showSensitiveContent == false)
@@ -844,39 +931,36 @@ class NoteLiveSet(u: Note) {
val relays = innerRelays.map { it }
val zaps = innerZaps.map { it }
val authorChanges = innerMetadata.map {
it.note.author
}.distinctUntilChanged()
val authorChanges = innerMetadata.map { it.note.author }.distinctUntilChanged()
val hasEvent = innerMetadata.map {
it.note.event != null
}.distinctUntilChanged()
val hasEvent = innerMetadata.map { it.note.event != null }.distinctUntilChanged()
val hasReactions = innerZaps.combineWith(innerBoosts, innerReactions) { zapState, boostState, reactionState ->
zapState?.note?.zaps?.isNotEmpty() ?: false ||
boostState?.note?.boosts?.isNotEmpty() ?: false ||
reactionState?.note?.reactions?.isNotEmpty() ?: false
}.distinctUntilChanged()
val hasReactions =
innerZaps
.combineWith(innerBoosts, innerReactions) { zapState, boostState, reactionState ->
zapState?.note?.zaps?.isNotEmpty()
?: false ||
boostState?.note?.boosts?.isNotEmpty() ?: false ||
reactionState?.note?.reactions?.isNotEmpty() ?: false
}
.distinctUntilChanged()
val replyCount = innerReplies.map {
it.note.replies.size
}.distinctUntilChanged()
val replyCount = innerReplies.map { it.note.replies.size }.distinctUntilChanged()
val reactionCount = innerReactions.map {
it.note.reactions.values.sumOf { it.size }
}.distinctUntilChanged()
val reactionCount =
innerReactions
.map {
var total = 0
it.note.reactions.forEach { total += it.value.size }
total
}
.distinctUntilChanged()
val boostCount = innerBoosts.map {
it.note.boosts.size
}.distinctUntilChanged()
val boostCount = innerBoosts.map { it.note.boosts.size }.distinctUntilChanged()
val relayInfo = innerRelays.map {
it.note.relays
}
val relayInfo = innerRelays.map { it.note.relays }
val content = innerMetadata.map {
it.note.event?.content() ?: ""
}
val content = innerMetadata.map { it.note.event?.content() ?: "" }
fun isInUse(): Boolean {
return metadata.hasObservers() ||
@@ -918,7 +1002,7 @@ class NoteBundledRefresherFlow(val note: Note) {
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate() {
bundler.invalidate {
checkNotInMainThread()
stateFlow.emit(NoteState(note))
@@ -938,16 +1022,14 @@ class NoteBundledRefresherLiveData(val note: Note) : LiveData<NoteState>(NoteSta
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate() {
bundler.invalidate {
checkNotInMainThread()
postValue(NoteState(note))
}
}
fun <Y> map(
transform: (NoteState) -> Y
): NoteLoadingLiveData<Y> {
fun <Y> map(transform: (NoteState) -> Y): NoteLoadingLiveData<Y> {
val initialValue = this.value?.let { transform(it) }
val result = NoteLoadingLiveData(note, initialValue)
result.addSource(this) { x -> result.value = transform(x) }
@@ -976,8 +1058,7 @@ class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveDat
}
}
@Immutable
class NoteState(val note: Note)
@Immutable class NoteState(val note: Note)
object RelayBriefInfoCache {
val cache = LruCache<String, RelayBriefInfo?>(50)
@@ -985,8 +1066,9 @@ object RelayBriefInfoCache {
@Immutable
data class RelayBriefInfo(
val url: String,
val displayUrl: String = url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(),
val favIcon: String = "https://$displayUrl/favicon.ico".intern()
val displayUrl: String =
url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(),
val favIcon: String = "https://$displayUrl/favicon.ico".intern(),
)
fun get(url: String): RelayBriefInfo {

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: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(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.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
@@ -50,6 +70,7 @@ class User(val pubkeyHex: String) {
private set
fun pubkey() = Hex.decode(pubkeyHex)
fun pubkeyNpub() = pubkey().toNpub()
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
@@ -63,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
}
@@ -82,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? {
@@ -130,11 +152,12 @@ class User(val pubkeyHex: String) {
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()
}
}
@@ -142,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()
@@ -150,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 {
@@ -216,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)
@@ -224,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)
@@ -236,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)
@@ -246,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))
@@ -260,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
@@ -288,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? {
@@ -314,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> {
@@ -338,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 {
@@ -347,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
}
@@ -453,22 +506,14 @@ class UserLiveSet(u: User) {
val relays = innerRelays.map { it }
val relayInfo = innerRelayInfo.map { it }
val zaps = innerZaps.map { it }
val bookmarks = innerBookmarks.map {
it
}
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() ||
@@ -504,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)) {
@@ -518,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) }
@@ -548,7 +591,7 @@ class UserBundledRefresherFlow(val user: User) {
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate() {
bundler.invalidate {
checkNotInMainThread()
stateFlow.emit(UserState(user))
@@ -568,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
@@ -30,7 +50,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)
@@ -38,7 +58,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 {
@@ -50,16 +73,24 @@ 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): ZoomableUrlContent? {
@@ -71,7 +102,7 @@ class RichTextParser() {
description = frags["alt"],
hash = frags["x"],
blurhash = frags["blurhash"],
dim = frags["dim"]
dim = frags["dim"],
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip44UrlParser().parse(fullUrl)
@@ -80,7 +111,7 @@ class RichTextParser() {
description = frags["alt"],
hash = frags["x"],
blurhash = frags["blurhash"],
dim = frags["dim"]
dim = frags["dim"],
)
} else {
null
@@ -89,30 +120,30 @@ class RichTextParser() {
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 ->
parseMediaUrl(fullUrl)
}.associateBy { it.url }
val imagesForPager =
urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl) }.associateBy { it.url }
val imageList = imagesForPager.values.toList()
val emojiMap =
@@ -125,11 +156,17 @@ 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> {
private fun findTextSegments(
content: String,
images: Set<String>,
urls: Set<String>,
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
): ImmutableList<ParagraphState> {
var paragraphSegments = persistentListOf<ParagraphState>()
content.split('\n').forEach { paragraph ->
@@ -147,11 +184,12 @@ class RichTextParser() {
segments = segments.add(wordSegment)
}
val newSegments = if (isDirty) {
ParagraphState(segments, isRTL)
} else {
ParagraphState(persistentListOf<Segment>(RegularTextSegment(paragraph)), isRTL)
}
val newSegments =
if (isDirty) {
ParagraphState(segments, isRTL)
} else {
ParagraphState(persistentListOf<Segment>(RegularTextSegment(paragraph)), isRTL)
}
paragraphSegments = paragraphSegments.add(newSegments)
}
@@ -171,7 +209,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)
@@ -201,7 +245,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 {
@@ -212,7 +258,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)
@@ -261,53 +310,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
@@ -18,7 +38,7 @@ data class CashuToken(
val token: String,
val mint: String,
val totalAmount: Long,
val proofs: JsonNode
val proofs: JsonNode,
)
class CashuProcessor {
@@ -43,41 +63,49 @@ class CashuProcessor {
}
}
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.totalAmount * 1000, // Make invoice and leave room for fees
message = "Calculate Fees for Cashu",
onSuccess = { baseInvoice ->
feeCalculator(
token.mint,
baseInvoice,
onSuccess = { fees ->
LightningAddressResolver().lnAddressInvoice(
lnaddress = lud16,
milliSats = (token.totalAmount - fees) * 1000, // Make invoice and leave room for fees
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
)
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,
)
}
}
@@ -86,7 +114,7 @@ class CashuProcessor {
invoice: String,
onSuccess: (Int) -> Unit,
onError: (String, String) -> Unit,
context: Context
context: Context,
) {
checkNotInMainThread()
@@ -101,10 +129,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()
@@ -114,7 +139,7 @@ class CashuProcessor {
if (feeCost != null) {
onSuccess(
feeCost
feeCost,
)
} else {
val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null }
@@ -124,16 +149,26 @@ 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_successful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message))
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, fees: Int, 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 url = token.mint + "/melt" // Melt cashu tokens at Mint
@@ -146,10 +181,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()
@@ -160,7 +192,11 @@ class CashuProcessor {
if (successful) {
onSuccess(
context.getString(R.string.cashu_successful_redemption),
context.getString(R.string.cashu_successful_redemption_explainer, token.totalAmount.toString(), fees.toString())
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 }
@@ -170,12 +206,15 @@ 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_successful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message))
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,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
@@ -20,7 +40,7 @@ class FileHeader(
val hash: String,
val size: Int,
val dim: String?,
val blurHash: String?
val blurHash: String?,
) {
companion object {
suspend fun prepare(
@@ -28,7 +48,7 @@ class FileHeader(
mimeType: String?,
dimPrecomputed: String?,
onReady: (FileHeader) -> Unit,
onError: () -> Unit
onError: (String?) -> Unit,
) {
try {
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
@@ -36,11 +56,11 @@ class FileHeader(
if (imageData != null) {
prepare(imageData, mimeType, dimPrecomputed, onReady, onError)
} else {
onError()
onError(null)
}
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
onError()
onError(e.message)
}
}
@@ -49,81 +69,113 @@ class FileHeader(
mimeType: String?,
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 dim = "${mBitmap.width}x${mBitmap.height}"
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
if (aspectRatio > 1) {
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt()), dim)
} else if (aspectRatio < 1) {
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9), dim)
} else {
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
}
} 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(
val intArray = IntArray(mBitmap.width * mBitmap.height)
mBitmap.getPixels(
intArray,
0,
thumbnail.width,
mBitmap.width,
0,
0,
thumbnail.width,
thumbnail.height
mBitmap.width,
mBitmap.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)
}
}
val dim = "${mBitmap.width}x${mBitmap.height}"
if (newDim != "0x0") {
Pair(blurhash, newDim)
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
if (aspectRatio > 1) {
Pair(
BlurHash.encode(
intArray,
mBitmap.width,
mBitmap.height,
9,
(9 * (1 / aspectRatio)).roundToInt(),
),
dim,
)
} else if (aspectRatio < 1) {
Pair(
BlurHash.encode(
intArray,
mBitmap.width,
mBitmap.height,
(9 * aspectRatio).roundToInt(),
9,
),
dim,
)
} else {
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
}
} 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, null)
Pair(null, null)
}
} else {
Pair(null, null)
}
onReady(FileHeader(mimeType, hash, size, dim, blurHash))
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError()
onError(e.message)
}
}
}
@@ -143,7 +195,8 @@ fun MediaMetadataRetriever.getThumbnail(): Bitmap? {
// 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
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)
@@ -186,15 +239,21 @@ class ByteArrayMediaDataSource(var imageData: ByteArray) : MediaDataSource() {
}
@Throws(IOException::class)
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
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
}
val newSize =
if (position + size > imageData.size) {
size - ((position.toInt() + size) - imageData.size)
} else {
size
}
imageData.copyInto(buffer, offset, position.toInt(), position.toInt() + newSize)

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
@@ -22,13 +42,12 @@ object HttpClient {
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()
private var internalProxy: Proxy? by
Delegates.observable(null) { _, oldValue, newValue ->
if (oldValue != newValue) {
proxyChangeListeners.forEach { it() }
}
}
}
fun start(proxy: Proxy?) {
if (internalProxy != proxy) {
@@ -63,10 +82,11 @@ object HttpClient {
@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()
val requestWithUserAgent: Request =
originalRequest
.newBuilder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.build()
return chain.proceed(requestWithUserAgent)
}
}
@@ -89,7 +109,11 @@ object HttpClient {
return internalProxy
}
fun initProxy(useProxy: Boolean, hostname: String, port: Int): Proxy? {
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
@@ -17,7 +37,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,46 +52,58 @@ 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) {
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,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.fasterxml.jackson.module.kotlin.jacksonObjectMapper
@@ -23,7 +43,11 @@ class Nip05NostrAddressVerifier() {
return null
}
suspend fun fetchNip05Json(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
suspend fun fetchNip05Json(
nip05: String,
onSuccess: (String) -> Unit,
onError: (String) -> Unit,
) = withContext(Dispatchers.IO) {
checkNotInMainThread()
val url = assembleUrl(nip05)
@@ -34,35 +58,54 @@ class Nip05NostrAddressVerifier() {
}
try {
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
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()
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")
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()
}
})
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) {
suspend fun verifyNip05(
nip05: String,
onSuccess: (String) -> Unit,
onError: (String) -> Unit,
) {
// check fails on tests
checkNotInMainThread()
@@ -76,19 +119,21 @@ class Nip05NostrAddressVerifier() {
// 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 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 user =
if (parts.size == 2) {
parts[0].lowercase()
} else {
"_"
}
val hexKey = nip05url?.get("names")?.get(user)?.asText()
@@ -98,7 +143,7 @@ class Nip05NostrAddressVerifier() {
onSuccess(hexKey)
}
},
onError = onError
onError = onError,
)
}
}

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
@@ -16,7 +36,7 @@ object Nip11CachedRetriever {
suspend fun loadRelayInfo(
dirtyUrl: String,
onInfo: (RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
) {
val url = retriever.cleanUrl(dirtyUrl)
val doc = relayInformationDocumentCache.get(url)
@@ -24,15 +44,16 @@ object Nip11CachedRetriever {
if (doc != null) {
onInfo(doc)
} else {
Nip11Retriever().loadRelayInfo(
url,
dirtyUrl,
onInfo = {
relayInformationDocumentCache.put(url, it)
onInfo(it)
},
onError
)
Nip11Retriever()
.loadRelayInfo(
url,
dirtyUrl,
onInfo = {
relayInformationDocumentCache.put(url, it)
onInfo(it)
},
onError,
)
}
}
}
@@ -42,14 +63,12 @@ class Nip11Retriever {
FAIL_TO_ASSEMBLE_URL,
FAIL_TO_REACH_SERVER,
FAIL_TO_PARSE_RESULT,
FAIL_WITH_HTTP_STATUS
FAIL_WITH_HTTP_STATUS,
}
fun cleanUrl(dirtyUrl: String): String {
return if (dirtyUrl.contains("://")) {
dirtyUrl
.replace("wss://", "https://")
.replace("ws://", "http://")
dirtyUrl.replace("wss://", "https://").replace("ws://", "http://")
} else {
"https://$dirtyUrl"
}
@@ -59,20 +78,20 @@ class Nip11Retriever {
url: String,
dirtyUrl: String,
onInfo: (RelayInformation) -> Unit,
onError: (String, ErrorCode, String?) -> Unit
onError: (String, ErrorCode, String?) -> Unit,
) {
try {
val request: Request = Request
.Builder()
.header("Accept", "application/nostr+json")
.url(url)
.build()
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) {
override fun onResponse(
call: Call,
response: Response,
) {
checkNotInMainThread()
response.use {
val body = it.body.string()
@@ -83,17 +102,24 @@ class Nip11Retriever {
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)
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) {
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)

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 androidx.compose.runtime.Immutable
@@ -5,7 +25,8 @@ import java.util.regex.Pattern
@Immutable
class Nip30CustomEmoji {
val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
val customEmojiPattern: Pattern =
Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
fun buildArray(input: String): List<String> {
val matcher = customEmojiPattern.matcher(input)

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 java.net.URI

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.note
import android.net.Uri
@@ -18,11 +38,12 @@ object Nip47WalletConnectParser {
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 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")

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
@@ -14,7 +34,7 @@ object Nip96MediaServers {
ServerName("Nostrage", "https://nostrage.com"),
ServerName("Sove", "https://sove.rent"),
ServerName("Sovbit", "https://files.sovbit.host"),
ServerName("Void.cat", "https://void.cat")
ServerName("Void.cat", "https://void.cat"),
)
data class ServerName(val name: String, val baseUrl: String)
@@ -39,7 +59,7 @@ class Nip96Retriever {
@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()
@JsonProperty("plans") val plans: Map<PlanName, Plan> = mapOf(),
)
data class Plan(
@@ -48,45 +68,46 @@ class Nip96Retriever {
@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()
@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)
val mapper =
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, ServerInfo::class.java)
}
suspend fun loadInfo(
baseUrl: String
): ServerInfo {
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()
val request: Request =
Request.Builder()
.header("Accept", "application/nostr+json")
.url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json")
.build()
HttpClient.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) {
Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e)
throw e
HttpClient.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) {
Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e)
throw e
}
}
}
}
}
typealias PlanName = String
typealias MimeType = 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 android.content.ContentResolver
@@ -28,7 +48,6 @@ 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?,
@@ -37,13 +56,24 @@ class Nip96Uploader(val account: Account?) {
sensitiveContent: String?,
server: Nip96MediaServers.ServerName,
contentResolver: ContentResolver,
onProgress: (percentage: Float) -> Unit
onProgress: (percentage: Float) -> Unit,
): PartialEvent {
val serverInfo = Nip96Retriever().loadInfo(
server.baseUrl
)
val serverInfo =
Nip96Retriever()
.loadInfo(
server.baseUrl,
)
return uploadImage(uri, contentType, size, alt, sensitiveContent, serverInfo, contentResolver, onProgress)
return uploadImage(
uri,
contentType,
size,
alt,
sensitiveContent,
serverInfo,
contentResolver,
onProgress,
)
}
suspend fun uploadImage(
@@ -54,26 +84,33 @@ class Nip96Uploader(val account: Account?) {
sensitiveContent: String?,
server: Nip96Retriever.ServerInfo,
contentResolver: ContentResolver,
onProgress: (percentage: Float) -> Unit
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
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"
}
checkNotNull(imageInputStream) { "Can't open the image input stream" }
return uploadImage(imageInputStream, length, myContentType, alt, sensitiveContent, server, onProgress)
return uploadImage(
imageInputStream,
length,
myContentType,
alt,
sensitiveContent,
server,
onProgress,
)
}
suspend fun uploadImage(
@@ -83,45 +120,44 @@ class Nip96Uploader(val account: Account?) {
alt: String?,
sensitiveContent: String?,
server: Nip96Retriever.ServerInfo,
onProgress: (percentage: Float) -> Unit
onProgress: (percentage: Float) -> Unit,
): PartialEvent {
checkNotInMainThread()
val fileName = randomChars()
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClient.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)
}
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) }
}
)
.build()
.addFormDataPart(
"file",
"$fileName.$extension",
object : RequestBody() {
override fun contentType() = contentType?.toMediaType()
nip98Header(server.apiUrl)?.let {
requestBuilder.addHeader("Authorization", it)
}
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}")
@@ -153,25 +189,25 @@ class Nip96Uploader(val account: Account?) {
suspend fun delete(
hash: String,
contentType: String?,
server: Nip96Retriever.ServerInfo
server: Nip96Retriever.ServerInfo,
): Boolean {
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClient.getHttpClient()
val requestBuilder = Request.Builder()
nip98Header(server.apiUrl)?.let {
requestBuilder.addHeader("Authorization", it)
}
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()
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) {
@@ -189,7 +225,7 @@ class Nip96Uploader(val account: Account?) {
private suspend fun waitProcessing(
result: Nip96Result,
server: Nip96Retriever.ServerInfo,
onProgress: (percentage: Float) -> Unit
onProgress: (percentage: Float) -> Unit,
): PartialEvent {
val client = HttpClient.getHttpClient()
var currentResult = result
@@ -197,16 +233,15 @@ class Nip96Uploader(val account: Account?) {
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()
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())
}
it.body.use { currentResult = parseResults(it.string()) }
}
}
@@ -226,14 +261,17 @@ class Nip96Uploader(val account: Account?) {
suspend fun nip98Header(url: String): String? {
return withTimeoutOrNull(5000) {
suspendCancellableCoroutine { continuation ->
nip98Header(url, "POST") { authorizationToken ->
continuation.resume(authorizationToken)
}
nip98Header(url, "POST") { authorizationToken -> continuation.resume(authorizationToken) }
}
}
}
fun nip98Header(url: String, method: String, file: ByteArray? = null, onReady: (String?) -> Unit) {
fun nip98Header(
url: String,
method: String,
file: ByteArray? = null,
onReady: (String?) -> Unit,
) {
val myAccount = account
if (myAccount == null) {
@@ -249,31 +287,31 @@ class Nip96Uploader(val account: Account?) {
data class DeleteResult(
val status: String?,
val message: String?
val message: String?,
)
private fun parseDeleteResults(body: String): DeleteResult {
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
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,
@JsonProperty("processing_url") val processingUrl: String? = null,
val percentage: Int? = null,
@JsonProperty("nip94_event")
val nip94Event: PartialEvent? = null
@JsonProperty("nip94_event") val nip94Event: PartialEvent? = null,
)
class PartialEvent(
val tags: Array<Array<String>>? = null,
val content: String? = null
val content: String? = null,
)
private fun parseResults(body: String): Nip96Result {
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
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
@@ -47,33 +67,36 @@ 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,
),
)
}
@@ -81,129 +104,163 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
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
)
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, MuteListEvent.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.value)?.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 {
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()) }
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
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,
),
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.value, 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) {
// Avoid decrypting over and over again if the event already exist.
if (LocalCache.getNoteIfExists(event.id) != null) return
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
event.cachedGift(account.signer) {
this.consume(it, relay)
}
event.cachedGift(account.signer) { this.consume(it, relay) }
}
if (event is SealedGossipEvent) {
// Avoid decrypting over and over again if the event already exist.
if (LocalCache.getNoteIfExists(event.id) != null) return
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
event.cachedGossip(account.signer) {
LocalCache.justConsume(it, relay)
}
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)
@@ -215,13 +272,15 @@ 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) {
noteEvent.cachedGift(account.signer) { gift ->
markInnerAsSeenOnRelay(gift, privKey, relay)
}
noteEvent.cachedGift(account.signer) { gift -> markInnerAsSeenOnRelay(gift, privKey, relay) }
} else if (noteEvent is SealedGossipEvent) {
noteEvent.cachedGossip(account.signer) { rumor ->
markInnerAsSeenOnRelay(rumor, privKey, relay)
@@ -231,44 +290,54 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
override fun updateChannelFilters() {
return if (hasLoadedTheBasics[account.userProfile()] != null) {
// gets everthing about the user logged in
accountChannel.typedFilters = listOfNotNull(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
createNotificationFilter(),
createGiftWrapsToMeFilter(),
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter(),
createAccountBookmarkListFilter(),
createAccountLastPostsListFilter(),
createOtherAccountsBaseFilter()
).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) {
account.createAuthEvent(relay, challenge) {
Client.send(
it,
relay.url
relay.url,
)
}
}
}
override fun notify(relay: Relay, description: String) {
override fun notify(
relay: Relay,
description: String,
) {
super.notify(relay, description)
if (this::account.isInitialized) {

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,32 +37,41 @@ 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? {
val followingEvents = account.selectedChatsFollowList()
@@ -50,12 +79,15 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
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,
),
)
}
@@ -66,12 +98,14 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
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,
),
)
}
}
@@ -84,31 +118,39 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
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 = listOfNotNull(
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
@@ -21,6 +41,7 @@ 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>()
@@ -30,70 +51,105 @@ abstract class NostrDataSource(val debugName: String) {
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, afterEOSE: Boolean) {
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))
}
consume(event, relay)
if (afterEOSE) {
// Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url}: ${event.kind}")
consume(event, relay)
if (afterEOSE) {
markAsEOSE(subscriptionId, 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 onError(error: Error, subscriptionId: String, relay: Relay) {
// Log.e("ERROR", "Relay ${relay.url}: ${error.message}")
}
override fun onSendResponse(
eventId: String,
success: Boolean,
message: String,
relay: Relay,
) {
if (success) {
markAsSeenOnRelay(eventId, relay)
}
}
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."
// }}")
override fun onAuth(
relay: Relay,
challenge: String,
) {
auth(relay, challenge)
}
if (type == Relay.StateType.EOSE && subscriptionId != null && subscriptionId in subscriptions.keys) {
markAsEOSE(subscriptionId, relay)
override fun onNotify(
relay: Relay,
description: String,
) {
notify(relay, description)
}
}
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)
}
}
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()
@@ -144,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
@@ -155,9 +211,7 @@ abstract class NostrDataSource(val debugName: String) {
}
fun resetFilters() {
scope.launch(Dispatchers.IO) {
resetFiltersSuspend()
}
scope.launch(Dispatchers.IO) { resetFiltersSuspend() }
}
fun resetFiltersSuspend() {
@@ -177,32 +231,56 @@ abstract class NostrDataSource(val debugName: String) {
subscriptions.values.forEach { updatedSubscription ->
val updatedSubscriptionNewFilters = updatedSubscription.typedFilters
if (updatedSubscription.id in currentFilters.keys) {
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)
}
}
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 (updatedSubscriptionNewFilters == 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) {
Log.d(
this@NostrDataSource.javaClass.simpleName,
"Update Filter 1 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}",
)
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
}
} else {
// hasn't changed, does nothing.
if (active) {
Log.d(
this@NostrDataSource.javaClass.simpleName,
"Update Filter 2 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}",
)
Client.sendFilterOnlyIfDisconnected(
updatedSubscription.id,
updatedSubscriptionNewFilters,
)
}
}
}
} else {
// was not active and becomes active, sends the filter.
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
if (active) {
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
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)
}
}
}
}
@@ -212,22 +290,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) {
open fun markAsEOSE(
subscriptionId: String,
relay: Relay,
) {
subscriptions[subscriptionId]?.updateEOSE(
TimeUtils.oneMinuteAgo(), // in case people's clock is slighly off.
relay.url
// 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 notify(relay: Relay, description: String) = Unit
open fun auth(
relay: Relay,
challenge: String,
) = Unit
open fun notify(
relay: Relay,
description: String,
) = Unit
}

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.Amethyst
@@ -28,13 +48,14 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
override fun start() {
job?.cancel()
job = scope.launch(Dispatchers.IO) {
account.liveDiscoveryFollowLists.collect {
if (this@NostrDiscoveryDataSource::account.isInitialized) {
invalidateFilters()
job =
scope.launch(Dispatchers.IO) {
account.liveDiscoveryFollowLists.collect {
if (this@NostrDiscoveryDataSource::account.isInitialized) {
invalidateFilters()
}
}
}
}
super.start()
}
@@ -51,43 +72,62 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = follows,
kinds = listOf(ClassifiedsEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.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()
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,
),
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()
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,
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
)
)
}
},
)
}
@@ -97,24 +137,34 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
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
)
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.value)?.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,
),
)
}
},
)
}
@@ -125,26 +175,37 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
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.value)?.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,
),
),
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
)
filter =
JsonFilter(
ids = followChats,
kinds = listOf(ChannelCreateEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
} else {
null
}
},
)
}
@@ -153,12 +214,17 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
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.value)?.relayList
)
filter =
JsonFilter(
authors = follows,
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
@@ -169,16 +235,23 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
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.value)?.relayList
)
)
}
@@ -189,16 +262,23 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
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.value)?.relayList
)
)
}
@@ -209,16 +289,24 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
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.value)?.relayList
)
)
}
@@ -229,16 +317,24 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
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.value)?.relayList
)
)
}
@@ -249,16 +345,23 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
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.value)?.relayList
)
)
}
@@ -269,37 +372,52 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
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.value)?.relayList
)
)
}
val discoveryFeedChannel = requestNewChannel() { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultDiscoveryFollowList.value, 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(createMarketplaceFilter())
.plus(
listOfNotNull(
createLiveStreamTagsFilter(),
createLiveStreamGeohashesFilter(),
createCommunitiesFilter(),
createCommunitiesTagsFilter(),
createCommunitiesGeohashesFilter(),
createPublicChatsTagsFilter(),
createPublicChatsGeohashesFilter()
discoveryFeedChannel.typedFilters =
createLiveStreamFilter()
.plus(createPublicChatFilter())
.plus(createMarketplaceFilter())
.plus(
listOfNotNull(
createLiveStreamTagsFilter(),
createLiveStreamGeohashesFilter(),
createCommunitiesFilter(),
createCommunitiesTagsFilter(),
createCommunitiesGeohashesFilter(),
createPublicChatsTagsFilter(),
createPublicChatsGeohashesFilter(),
),
)
).ifEmpty { null }
.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,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.Amethyst
@@ -34,17 +54,16 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
override fun start() {
job?.cancel()
job = scope.launch(Dispatchers.IO) {
// creates cache on main
withContext(Dispatchers.Main) {
account.userProfile().live()
}
account.liveHomeFollowLists.collect {
if (this@NostrHomeDataSource::account.isInitialized) {
invalidateFilters()
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()
}
@@ -59,25 +78,31 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
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.value)?.relayList
)
)
}
@@ -88,16 +113,32 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
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.value)?.relayList
)
)
}
@@ -108,16 +149,32 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
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.value)?.relayList
)
)
}
@@ -128,36 +185,51 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
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.value)?.relayList
)
)
}
val followAccountChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultHomeFollowList.value, 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
@@ -13,24 +33,25 @@ class NostrLnZapPaymentResponseDataSource(
private val fromServiceHex: String,
private val toUserHex: String,
private val replyingToHex: String,
private val authSigner: NostrSigner
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
)
)
}
@@ -42,13 +63,16 @@ 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)
RelayAuthEvent.create(relay.url, challenge, authSigner) {
Client.send(
it,
relay.url
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
@@ -36,75 +56,90 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
return null
}
val hexToWatch = try {
val isAStraightHex = if (HexValidator.isHex(mySearchString)) {
Hex.decode(mySearchString).toHexKey()
} else {
val hexToWatch =
try {
val isAStraightHex =
if (HexValidator.isHex(mySearchString)) {
Hex.decode(mySearchString).toHexKey()
} else {
null
}
Nip19.uriToRoute(mySearchString)?.hex ?: isAStraightHex
} catch (e: Exception) {
null
}
Nip19.uriToRoute(mySearchString)?.hex ?: isAStraightHex
} catch (e: Exception) {
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
)
)
),
)
}

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,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
@@ -22,31 +42,39 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
private var addressesToWatch = setOf<Note>()
private fun createReactionsToWatchInAddressFilter(): List<TypedFilter>? {
val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch.filter { it.address() != null }
val addressesToWatch =
(
eventsToWatch.filter { it.address() != null } +
addressesToWatch.filter { it.address() != null }
)
.toSet()
if (addressesToWatch.isEmpty()) {
return null
}
return groupByEOSEPresence(eventsToWatch).mapNotNull {
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
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,
),
tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }),
since = findMinimumEOSEs(it),
limit = 1000 // Max amount of "replies" to download on a specific event.
)
)
}
}
@@ -63,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 = 5
)
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 = 5
)
filter =
JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),
limit = 5,
),
)
}
}
@@ -92,36 +122,37 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
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 it.map { it.idHex }),
since = findMinimumEOSEs(it),
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
@@ -131,41 +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
}
}
}
addressesToWatch.forEach {
val eose = it.lastReactionsDownloadTime[relayUrl]
if (eose == null) {
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
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()
}
// 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()
@@ -173,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) {

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
@@ -22,11 +42,12 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = firstTimers
)
)
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
authors = firstTimers,
),
),
)
}
@@ -37,52 +58,60 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
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
)
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,
),
),
)
)
}.flatten()
}
.flatten()
}
val userChannel = requestNewChannel() { time, relayUrl ->
checkNotInMainThread()
val userChannel =
requestNewChannel { time, relayUrl ->
checkNotInMainThread()
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
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
}
}
}
}
}
override fun updateChannelFilters() {
checkNotInMainThread()
userChannel.typedFilters = listOfNotNull(
createUserMetadataFilter(),
createUserMetadataStatusReportFilter()
).flatten().ifEmpty { null }
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,30 +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.toList()
)
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,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.Amethyst
@@ -22,13 +42,14 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
override fun start() {
job?.cancel()
job = scope.launch(Dispatchers.IO) {
account.liveStoriesFollowLists.collect {
if (this@NostrVideoDataSource::account.isInitialized) {
invalidateFilters()
job =
scope.launch(Dispatchers.IO) {
account.liveStoriesFollowLists.collect {
if (this@NostrVideoDataSource::account.isInitialized) {
invalidateFilters()
}
}
}
}
super.start()
}
@@ -42,12 +63,17 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
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.value)?.relayList
)
filter =
JsonFilter(
authors = follows,
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
limit = 200,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultStoriesFollowList.value)
?.relayList,
),
)
}
@@ -58,48 +84,70 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
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.value)?.relayList
)
)
}
fun createFollowGeohashesFilter(): TypedFilter? {
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.value)?.relayList
)
)
}
val videoFeedChannel = requestNewChannel() { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultStoriesFollowList.value, 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
@@ -6,8 +26,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.BuildConfig
import okhttp3.Request
@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)
@@ -32,16 +51,18 @@ object OnlineChecker {
Log.d("OnlineChecker", "isOnline $url")
return try {
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
val request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
val result = HttpClient.getHttpClient().newCall(request).execute().use {
checkNotInMainThread()
it.isSuccessful
}
val result =
HttpClient.getHttpClient().newCall(request).execute().use {
checkNotInMainThread()
it.isSuccessful
}
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
result
} catch (e: Exception) {

View File

@@ -1,10 +1,35 @@
/**
* 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
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 {

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,6 +28,7 @@ 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
@@ -19,13 +40,12 @@ 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(
@@ -37,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>()
@@ -65,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(
@@ -87,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)
@@ -116,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,
),
)
}
}
@@ -162,7 +188,7 @@ class ZapPaymentHandler(val account: Account) {
message: String,
zapType: LnZapEvent.ZapType,
overrideUser: User? = null,
onReady: (String?) -> Unit
onReady: (String?) -> Unit,
) {
if (zapType != LnZapEvent.ZapType.NONZAP) {
account.createZapRequestFor(note, pollOption, message, zapType, overrideUser) { zapRequest ->
@@ -184,51 +210,51 @@ class ZapPaymentHandler(val account: Account) {
onProgress: (percent: Float) -> Unit,
onPayInvoiceThroughIntent: (String) -> Unit,
zapType: LnZapEvent.ZapType,
overrideUser: User? = null
overrideUser: User? = null,
) {
onProgress(0.05f)
prepareZapRequestIfNeeded(note, pollOption, message, zapType, overrideUser) { zapRequestJson ->
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"
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)
}
},
onError = onError,
onProgress = onProgress,
context = context
)
} else {
onProgress(1f)
}
},
)
onProgress(0.8f)
} else {
onPayInvoiceThroughIntent(it)
onProgress(0f)
}
},
onError = onError,
onProgress = onProgress,
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.amethyst.service.lnurl
import android.content.Context
@@ -35,7 +55,7 @@ class LightningAddressResolver() {
lnaddress: String,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
context: Context
context: Context,
) {
checkNotInMainThread()
@@ -46,17 +66,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
}
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) {
@@ -65,11 +86,12 @@ 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(),
),
)
}
}
@@ -78,10 +100,11 @@ class LightningAddressResolver() {
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,
url,
lnaddress
)
lnaddress,
),
)
}
}
@@ -93,7 +116,7 @@ class LightningAddressResolver() {
nostrRequest: String? = null,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
context: Context
context: Context,
) {
checkNotInMainThread()
@@ -107,10 +130,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,20 +142,23 @@ 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),
)
}
}
}
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,
)
}
@@ -143,7 +170,7 @@ class LightningAddressResolver() {
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
context: Context
context: Context,
) {
val mapper = jacksonObjectMapper()
@@ -152,22 +179,27 @@ 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) {
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 +214,78 @@ 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) {
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
@@ -39,7 +59,10 @@ class EventNotificationConsumer(private val applicationContext: Context) {
}
}
private suspend fun consumeIfMatchesAccount(pushWrappedEvent: GiftWrapEvent, account: Account) {
private suspend fun consumeIfMatchesAccount(
pushWrappedEvent: GiftWrapEvent,
account: Account,
) {
pushWrappedEvent.cachedGift(account.signer) { notificationEvent ->
LocalCache.justConsume(notificationEvent, null)
@@ -55,14 +78,16 @@ class EventNotificationConsumer(private val applicationContext: Context) {
}
}
private fun unwrapAndConsume(event: Event, account: Account, onReady: (Event) -> Unit) {
private fun unwrapAndConsume(
event: Event,
account: Account,
onReady: (Event) -> Unit,
) {
if (!LocalCache.justVerify(event)) return
when (event) {
is GiftWrapEvent -> {
event.cachedGift(account.signer) {
unwrapAndConsume(it, account, onReady)
}
event.cachedGift(account.signer) { unwrapAndConsume(it, account, onReady) }
}
is SealedGossipEvent -> {
event.cachedGossip(account.signer) {
@@ -78,8 +103,12 @@ class EventNotificationConsumer(private val applicationContext: Context) {
}
}
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
@@ -88,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) {
@@ -98,19 +128,23 @@ 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
@@ -119,12 +153,18 @@ class EventNotificationConsumer(private val applicationContext: Context) {
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) {
@@ -132,21 +172,26 @@ class EventNotificationConsumer(private val applicationContext: Context) {
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
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) } ?: return
val noteZapped =
event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
@@ -161,17 +206,33 @@ class EventNotificationConsumer(private val applicationContext: Context) {
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)
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)
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)
notificationManager()
.sendZapNotification(
event.id,
content,
title,
userPicture,
noteUri,
applicationContext,
)
}
}
}
@@ -179,6 +240,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
}
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,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.util.Log
@@ -14,13 +34,13 @@ 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
onReady: (List<RelayAuthEvent>) -> Unit,
) {
if (remainingTos.isEmpty()) {
onReady(output)
@@ -39,36 +59,39 @@ class RegisterAccounts(
private suspend fun signEventsToProveControlOfAccounts(
accounts: List<AccountInfo>,
notificationToken: String,
onReady: (List<RelayAuthEvent>) -> Unit
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 readyToSend =
accounts.mapNotNull {
val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)
if (acc != null && acc.isWriteable()) {
val readRelays =
acc.userProfile().latestContactList?.relays() ?: acc.backupContactList?.relays()
val relayToUse = readRelays?.firstNotNullOfOrNull { if (it.value.read) it.key else null }
if (relayToUse != null) {
Pair(acc, relayToUse)
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
onReady,
)
}
fun postRegistrationEvent(events: List<RelayAuthEvent>) {
try {
val jsonObject = """{
val jsonObject =
"""{
"events": [ ${events.joinToString(", ") { it.toJson() }} ]
}
"""
@@ -76,32 +99,31 @@ 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 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"
}
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) {
signEventsToProveControlOfAccounts(accounts, notificationToken) {
postRegistrationEvent(it)
}
suspend fun go(notificationToken: String) =
withContext(Dispatchers.IO) {
signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) }
PushNotificationUtils.hasInit = true
}
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,44 @@
/**
* 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
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,11 +48,14 @@ 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(
{
@@ -39,7 +65,7 @@ object PlaybackClientController {
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
}
},
MoreExecutors.directExecutor()
MoreExecutors.directExecutor(),
)
} catch (e: Exception) {
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)

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.content.Intent
@@ -27,12 +47,14 @@ class PlaybackService : MediaSessionService() {
fun newProgressiveDataSource(): MediaSource.Factory {
return ProgressiveMediaSource.Factory(
(applicationContext as Amethyst).videoCache.get(HttpClient.getHttpClient())
(applicationContext as Amethyst).videoCache.get(HttpClient.getHttpClient()),
)
}
fun lazyHlsDS(): MultiPlayerPlaybackManager {
managerHls?.let { return it }
managerHls?.let {
return it
}
val newInstance = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerHls = newInstance
@@ -40,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
@@ -71,7 +98,8 @@ class PlaybackService : MediaSessionService() {
val toDestroyProgressive = managerProgressive
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerProgressive = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
managerProgressive =
MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
toDestroyHls?.releaseAppPlayers()
toDestroyProgressive?.releaseAppPlayers()
@@ -105,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)
@@ -153,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,40 @@
/**
* 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.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) {
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)
}

View File

@@ -1,14 +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.service.previews
import androidx.compose.runtime.Immutable
import okhttp3.MediaType
import java.net.URL
@Immutable
data class UrlInfoItem(
class UrlInfoItem(
val url: String = "",
val title: String = "",
val description: String = "",
val image: String = ""
val image: String = "",
val mimeType: MediaType,
) {
val verifiedUrl = kotlin.runCatching { URL(url) }.getOrNull()
val imageUrlFullPath =
@@ -18,6 +40,10 @@ data class UrlInfoItem(
image
}
fun fetchComplete(): Boolean {
return url.isNotEmpty() && image.isNotEmpty()
}
fun allFetchComplete(): Boolean {
return title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()
}

View File

@@ -1,8 +1,30 @@
/**
* 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 com.vitorpamplona.amethyst.service.HttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
@@ -12,55 +34,93 @@ private const val ATTRIBUTE_VALUE_PROPERTY = "property"
private const val ATTRIBUTE_VALUE_NAME = "name"
private const val ATTRIBUTE_VALUE_ITEMPROP = "itemprop"
/*for <meta itemprop=... to get title */
private val META_X_TITLE = arrayOf(
"og:title", "\"og:title\"", "'og:title'", "name", "\"name\"", "'name'",
"twitter:title",
"\"twitter:title\"",
"'twitter:title'",
"title",
"\"title\"",
"'title'"
)
// for <meta itemprop=... to get title
private val META_X_TITLE =
arrayOf(
"og:title",
"\"og:title\"",
"'og:title'",
"name",
"\"name\"",
"'name'",
"twitter:title",
"\"twitter:title\"",
"'twitter:title'",
"title",
"\"title\"",
"'title'",
)
/*for <meta itemprop=... to get description */
private val META_X_DESCRIPTION = arrayOf(
"og:description", "\"og:description\"", "'og:description'",
"description", "\"description\"", "'description'",
"twitter:description",
"\"twitter:description\"",
"'twitter:description'",
"description",
"\"description\"",
"'description'"
)
// for <meta itemprop=... to get description
private val META_X_DESCRIPTION =
arrayOf(
"og:description",
"\"og:description\"",
"'og:description'",
"description",
"\"description\"",
"'description'",
"twitter:description",
"\"twitter:description\"",
"'twitter:description'",
"description",
"\"description\"",
"'description'",
)
/*for <meta itemprop=... to get image */
private val META_X_IMAGE = arrayOf(
"og:image", "\"og:image\"", "'og:image'",
"image", "\"image\"", "'image'",
"twitter:image",
"\"twitter:image\"",
"'twitter:image'"
)
// for <meta itemprop=... to get image
private val META_X_IMAGE =
arrayOf(
"og:image",
"\"og:image\"",
"'og:image'",
"image",
"\"image\"",
"'image'",
"twitter:image",
"\"twitter:image\"",
"'twitter:image'",
)
private const val CONTENT = "content"
suspend fun getDocument(url: String, timeOut: Int = 30000): Document =
suspend fun getDocument(
url: String,
timeOut: Int = 30000,
): UrlInfoItem =
withContext(Dispatchers.IO) {
val request: Request = Request.Builder().url(url).get().build()
val html = HttpClient.getHttpClient().newCall(request).execute().use {
HttpClient.getHttpClient().newCall(request).execute().use {
if (it.isSuccessful) {
it.body.string()
val mimeType =
it.headers.get("Content-Type")?.toMediaType()
?: throw IllegalArgumentException(
"Website returned unknown mimetype: ${it.headers.get("Content-Type")}",
)
if (mimeType.type == "text" && mimeType.subtype == "html") {
val document = Jsoup.parse(it.body.string())
parseHtml(url, document, mimeType)
} else if (mimeType.type == "image") {
UrlInfoItem(url, image = url, mimeType = mimeType)
} else if (mimeType.type == "video") {
UrlInfoItem(url, image = url, mimeType = mimeType)
} else {
throw IllegalArgumentException(
"Website returned unknown encoding for previews: $mimeType",
)
}
} else {
throw IllegalArgumentException("Website returned: " + it.code)
}
}
Jsoup.parse(html)
}
suspend fun parseHtml(url: String, document: Document): UrlInfoItem =
suspend fun parseHtml(
url: String,
document: Document,
type: MediaType,
): UrlInfoItem =
withContext(Dispatchers.IO) {
val metaTags = document.getElementsByTag(ELEMENT_TAG_META)
@@ -70,44 +130,53 @@ suspend fun parseHtml(url: String, document: Document): UrlInfoItem =
metaTags.forEach {
when (it.attr(ATTRIBUTE_VALUE_PROPERTY)) {
in META_X_TITLE -> if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION -> if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE -> if (image.isEmpty()) {
image = it.attr(CONTENT)
}
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
in META_X_TITLE -> if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION -> if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE -> if (image.isEmpty()) {
image = it.attr(CONTENT)
}
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
in META_X_TITLE -> if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION -> if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE -> if (image.isEmpty()) {
image = it.attr(CONTENT)
}
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
return@withContext UrlInfoItem(url, title, description, image)
return@withContext UrlInfoItem(url, title, description, image, type)
}
}
return@withContext UrlInfoItem(url, title, description, image)
return@withContext UrlInfoItem(url, title, description, image, type)
}

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.relays
import android.util.Log
@@ -13,8 +33,7 @@ import java.util.UUID
/**
* The Nostr Client manages multiple personae the user may switch between. Events are received and
* published through multiple relays.
* Events are stored with their respective persona.
* published through multiple relays. Events are stored with their respective persona.
*/
object Client : RelayPool.Listener {
private var listeners = setOf<Listener>()
@@ -22,7 +41,10 @@ object Client : RelayPool.Listener {
private var subscriptions = mapOf<String, List<TypedFilter>>()
@Synchronized
fun reconnect(relays: Array<Relay>?, onlyIfChanged: Boolean = false) {
fun reconnect(
relays: Array<Relay>?,
onlyIfChanged: Boolean = false,
) {
Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays")
checkNotInMainThread()
@@ -71,7 +93,7 @@ object Client : RelayPool.Listener {
fun sendFilter(
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<TypedFilter> = listOf()
filters: List<TypedFilter> = listOf(),
) {
checkNotInMainThread()
@@ -81,12 +103,12 @@ object Client : RelayPool.Listener {
fun sendFilterOnlyIfDisconnected(
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<TypedFilter> = listOf()
filters: List<TypedFilter> = listOf(),
) {
checkNotInMainThread()
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilterOnlyIfDisconnected()
RelayPool.sendFilterOnlyIfDisconnected(subscriptionId)
}
fun send(
@@ -94,7 +116,7 @@ object Client : RelayPool.Listener {
relay: String? = null,
feedTypes: Set<FeedType>? = null,
relayList: List<Relay>? = null,
onDone: (() -> Unit)? = null
onDone: (() -> Unit)? = null,
) {
checkNotInMainThread()
@@ -106,32 +128,31 @@ object Client : RelayPool.Listener {
val useConnectedRelayIfPresent = RelayPool.getRelays(relay)
if (useConnectedRelayIfPresent.isNotEmpty()) {
useConnectedRelayIfPresent.forEach {
it.send(signedEvent)
}
useConnectedRelayIfPresent.forEach { it.send(signedEvent) }
} else {
/** temporary connection */
newSporadicRelay(
relay,
feedTypes,
onConnected = { relay ->
relay.send(signedEvent)
},
onDone = onDone
onConnected = { relay -> relay.send(signedEvent) },
onDone = onDone,
)
}
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
private fun newSporadicRelay(
url: String,
feedTypes: Set<FeedType>?,
onConnected: (Relay) -> Unit,
onDone: (() -> Unit)?,
) {
val relay = Relay(url, true, true, feedTypes ?: emptySet())
RelayPool.addRelay(relay)
relay.connectAndRun {
allSubscriptions().forEach {
relay.sendFilter(requestId = it)
}
allSubscriptions().forEach { relay.sendFilter(requestId = it) }
onConnected(relay)
@@ -152,8 +173,17 @@ object Client : RelayPool.Listener {
subscriptions = subscriptions.minus(subscriptionId)
}
fun isActive(subscriptionId: String): Boolean {
return subscriptions.contains(subscriptionId)
}
@OptIn(DelicateCoroutinesApi::class)
override fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean) {
override fun onEvent(
event: Event,
subscriptionId: String,
relay: Relay,
afterEOSE: Boolean,
) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
@@ -162,7 +192,11 @@ object Client : RelayPool.Listener {
}
@OptIn(DelicateCoroutinesApi::class)
override fun onError(error: Error, subscriptionId: String, relay: Relay) {
override fun onError(
error: Error,
subscriptionId: String,
relay: Relay,
) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
@@ -171,7 +205,11 @@ object Client : RelayPool.Listener {
}
@OptIn(DelicateCoroutinesApi::class)
override fun onRelayStateChange(type: Relay.StateType, relay: Relay, channel: String?) {
override fun onRelayStateChange(
type: Relay.StateType,
relay: Relay,
channel: String?,
) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
@@ -180,7 +218,12 @@ object Client : RelayPool.Listener {
}
@OptIn(DelicateCoroutinesApi::class)
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
override fun onSendResponse(
eventId: String,
success: Boolean,
message: String,
relay: Relay,
) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
@@ -189,15 +232,19 @@ object Client : RelayPool.Listener {
}
@OptIn(DelicateCoroutinesApi::class)
override fun onAuth(relay: Relay, challenge: String) {
override fun onAuth(
relay: Relay,
challenge: String,
) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onAuth(relay, challenge) }
}
GlobalScope.launch(Dispatchers.Default) { listeners.forEach { it.onAuth(relay, challenge) } }
}
override fun onNotify(relay: Relay, description: String) {
override fun onNotify(
relay: Relay,
description: String,
) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
@@ -209,12 +256,16 @@ object Client : RelayPool.Listener {
listeners = listeners.plus(listener)
}
fun isSubscribed(listener: Listener): Boolean {
return listeners.contains(listener)
}
fun unsubscribe(listener: Listener) {
listeners = listeners.minus(listener)
}
fun allSubscriptions(): List<String> {
return subscriptions.keys.toList()
fun allSubscriptions(): Set<String> {
return subscriptions.keys
}
fun getSubscriptionFilters(subId: String): List<TypedFilter> {
@@ -222,28 +273,44 @@ object Client : RelayPool.Listener {
}
abstract class Listener {
/**
* A new message was received
*/
open fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean) = Unit
/** A new message was received */
open fun onEvent(
event: Event,
subscriptionId: String,
relay: Relay,
afterEOSE: Boolean,
) = Unit
/**
* A new or repeat message was received
*/
open fun onError(error: Error, subscriptionId: String, relay: Relay) = Unit
/** A new or repeat message was received */
open fun onError(
error: Error,
subscriptionId: String,
relay: Relay,
) = Unit
/**
* Connected to or disconnected from a relay
*/
open fun onRelayStateChange(type: Relay.StateType, relay: Relay, channel: String?) = Unit
/** Connected to or disconnected from a relay */
open fun onRelayStateChange(
type: Relay.StateType,
relay: Relay,
channel: String?,
) = Unit
/**
* When an relay saves or rejects a new event.
*/
open fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) = Unit
/** When an relay saves or rejects a new event. */
open fun onSendResponse(
eventId: String,
success: Boolean,
message: String,
relay: Relay,
) = Unit
open fun onAuth(relay: Relay, challenge: String) = Unit
open fun onAuth(
relay: Relay,
challenge: String,
) = Unit
open fun onNotify(relay: Relay, description: String) = Unit
open fun onNotify(
relay: Relay,
description: String,
) = Unit
}
}

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.relays
import com.vitorpamplona.amethyst.model.RelaySetupInfo
@@ -5,59 +25,154 @@ import com.vitorpamplona.amethyst.model.RelaySetupInfo
object Constants {
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
val activeTypesChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS)
val activeTypesGlobalChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
val activeTypesGlobalChats =
setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
val activeTypesSearch = setOf(FeedType.SEARCH)
fun convertDefaultRelays(): Array<Relay> {
return defaultRelays.map {
Relay(it.url, it.read, it.write, it.feedTypes)
}.toTypedArray()
return defaultRelays.map { Relay(it.url, it.read, it.write, it.feedTypes) }.toTypedArray()
}
val defaultRelays = arrayOf(
// Free relays for only DMs and Follows due to the amount of spam
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
val defaultRelays =
arrayOf(
// Free relays for only DMs and Follows due to the amount of spam
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
// Chats
RelaySetupInfo(
"wss://nostr.bitcoiner.social",
read = true,
write = true,
feedTypes = activeTypesChats,
),
RelaySetupInfo(
"wss://relay.nostr.bg",
read = true,
write = true,
feedTypes = activeTypesChats,
),
RelaySetupInfo(
"wss://nostr.oxtr.dev",
read = true,
write = true,
feedTypes = activeTypesChats,
),
RelaySetupInfo(
"wss://nostr-pub.wellorder.net",
read = true,
write = true,
feedTypes = activeTypesChats,
),
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesChats),
// Less Reliable
// NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true,
// feedTypes = activeTypes),
// NewRelayListViewModel.Relay("wss://nostr.onsats.org", read = true, write = true, feedTypes
// = activeTypes),
// NewRelayListViewModel.Relay("wss://nostr.sandwich.farm", read = true, write = true,
// feedTypes = activeTypes),
// NewRelayListViewModel.Relay("wss://relay.nostr.ch", read = true, write = true, feedTypes =
// activeTypes),
// NewRelayListViewModel.Relay("wss://nostr.zebedee.cloud", read = true, write = true,
// feedTypes = activeTypes),
// NewRelayListViewModel.Relay("wss://nostr.rocks", read = true, write = true, feedTypes =
// activeTypes),
// NewRelayListViewModel.Relay("wss://nostr.fmt.wiz.biz", read = true, write = true, feedTypes
// = activeTypes),
// NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes =
// activeTypes),
// Paid relays
RelaySetupInfo(
"wss://relay.snort.social",
read = true,
write = false,
feedTypes = activeTypesGlobalChats,
),
RelaySetupInfo(
"wss://relay.nostr.com.au",
read = true,
write = false,
feedTypes = activeTypesGlobalChats,
),
RelaySetupInfo(
"wss://eden.nostr.land",
read = true,
write = false,
feedTypes = activeTypesGlobalChats,
),
RelaySetupInfo(
"wss://nostr.milou.lol",
read = true,
write = false,
feedTypes = activeTypesGlobalChats,
),
RelaySetupInfo(
"wss://puravida.nostr.land",
read = true,
write = false,
feedTypes = activeTypesGlobalChats,
),
RelaySetupInfo(
"wss://nostr.wine",
read = true,
write = false,
feedTypes = activeTypesGlobalChats,
),
RelaySetupInfo(
"wss://nostr.inosta.cc",
read = true,
write = false,
feedTypes = activeTypesGlobalChats,
),
RelaySetupInfo(
"wss://atlas.nostr.land",
read = true,
write = false,
feedTypes = activeTypesGlobalChats,
),
RelaySetupInfo(
"wss://relay.orangepill.dev",
read = true,
write = false,
feedTypes = activeTypesGlobalChats,
),
RelaySetupInfo(
"wss://relay.nostrati.com",
read = true,
write = false,
feedTypes = activeTypesGlobalChats,
),
// Supporting NIP-50
RelaySetupInfo(
"wss://relay.nostr.band",
read = true,
write = false,
feedTypes = activeTypesSearch,
),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo(
"wss://relay.noswhere.com",
read = true,
write = false,
feedTypes = activeTypesSearch,
),
)
// Chats
RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesChats),
// Less Reliable
// NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes),
// NewRelayListViewModel.Relay("wss://nostr.onsats.org", read = true, write = true, feedTypes = activeTypes),
// NewRelayListViewModel.Relay("wss://nostr.sandwich.farm", read = true, write = true, feedTypes = activeTypes),
// NewRelayListViewModel.Relay("wss://relay.nostr.ch", read = true, write = true, feedTypes = activeTypes),
// NewRelayListViewModel.Relay("wss://nostr.zebedee.cloud", read = true, write = true, feedTypes = activeTypes),
// NewRelayListViewModel.Relay("wss://nostr.rocks", read = true, write = true, feedTypes = activeTypes),
// NewRelayListViewModel.Relay("wss://nostr.fmt.wiz.biz", read = true, write = true, feedTypes = activeTypes),
// NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = activeTypes),
// Paid relays
RelaySetupInfo("wss://relay.snort.social", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://puravida.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nostr.inosta.cc", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
// Supporting NIP-50
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch)
)
val forcedRelayForSearch = arrayOf(
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch)
)
val forcedRelayForSearch =
arrayOf(
RelaySetupInfo(
"wss://relay.nostr.band",
read = true,
write = false,
feedTypes = activeTypesSearch,
),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo(
"wss://relay.noswhere.com",
read = true,
write = false,
feedTypes = activeTypesSearch,
),
)
val forcedRelaysForSearchSet = forcedRelayForSearch.map { it.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.relays
import com.vitorpamplona.amethyst.model.User
@@ -9,7 +29,10 @@ class EOSETime(var time: Long) {
}
class EOSERelayList(var relayList: Map<String, EOSETime> = emptyMap()) {
fun addOrUpdate(relayUrl: String, time: Long) {
fun addOrUpdate(
relayUrl: String,
time: Long,
) {
val eose = relayList[relayUrl]
if (eose == null) {
relayList = relayList + Pair(relayUrl, EOSETime(time))
@@ -20,7 +43,11 @@ class EOSERelayList(var relayList: Map<String, EOSETime> = emptyMap()) {
}
class EOSEFollowList(var followList: Map<String, EOSERelayList> = emptyMap()) {
fun addOrUpdate(listCode: String, relayUrl: String, time: Long) {
fun addOrUpdate(
listCode: String,
relayUrl: String,
time: Long,
) {
val relayList = followList[listCode]
if (relayList == null) {
val newList = EOSERelayList()
@@ -33,7 +60,12 @@ class EOSEFollowList(var followList: Map<String, EOSERelayList> = emptyMap()) {
}
class EOSEAccount(var users: Map<User, EOSEFollowList> = emptyMap()) {
fun addOrUpdate(user: User, listCode: String, relayUrl: String, time: Long) {
fun addOrUpdate(
user: User,
listCode: String,
relayUrl: String,
time: Long,
) {
val followList = users[user]
if (followList == null) {
val newList = EOSEFollowList()

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.relays
import com.vitorpamplona.quartz.events.Event
@@ -10,66 +30,58 @@ class JsonFilter(
val since: Map<String, EOSETime>? = null,
val until: Long? = null,
val limit: Int? = null,
val search: String? = null
val search: String? = null,
) {
fun toJson(forRelay: String? = null): String {
val factory = Event.mapper.nodeFactory
val filter = factory.objectNode().apply {
ids?.run {
put(
"ids",
factory.arrayNode(ids.size).apply {
ids.forEach { add(it) }
}
)
}
authors?.run {
put(
"authors",
factory.arrayNode(authors.size).apply {
authors.forEach { add(it) }
}
)
}
kinds?.run {
put(
"kinds",
factory.arrayNode(kinds.size).apply {
kinds.forEach { add(it) }
}
)
}
tags?.run {
entries.forEach { kv ->
val filter =
factory.objectNode().apply {
ids?.run {
put(
"#${kv.key}",
factory.arrayNode(kv.value.size).apply {
kv.value.forEach { add(it) }
}
"ids",
factory.arrayNode(ids.size).apply { ids.forEach { add(it) } },
)
}
}
since?.run {
if (!isEmpty()) {
if (forRelay != null) {
val relaySince = get(forRelay)
if (relaySince != null) {
put("since", relaySince.time)
}
} else {
val jsonObjectSince = factory.objectNode()
entries.forEach { sincePairs ->
jsonObjectSince.put(sincePairs.key, "${sincePairs.value}")
}
put("since", jsonObjectSince)
authors?.run {
put(
"authors",
factory.arrayNode(authors.size).apply { authors.forEach { add(it) } },
)
}
kinds?.run {
put(
"kinds",
factory.arrayNode(kinds.size).apply { kinds.forEach { add(it) } },
)
}
tags?.run {
entries.forEach { kv ->
put(
"#${kv.key}",
factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } },
)
}
}
since?.run {
if (!isEmpty()) {
if (forRelay != null) {
val relaySince = get(forRelay)
if (relaySince != null) {
put("since", relaySince.time)
}
} else {
val jsonObjectSince = factory.objectNode()
entries.forEach { sincePairs ->
jsonObjectSince.put(sincePairs.key, "${sincePairs.value}")
}
put("since", jsonObjectSince)
}
}
}
until?.run { put("until", until) }
limit?.run { put("limit", limit) }
search?.run { put("search", search) }
}
until?.run { put("until", until) }
limit?.run { put("limit", limit) }
search?.run { put("search", search) }
}
return Event.mapper.writeValueAsString(filter)
}
}

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.relays
import android.util.Log
@@ -15,19 +35,26 @@ import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.lang.StringBuilder
import java.util.concurrent.atomic.AtomicBoolean
enum class FeedType {
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH, WALLET_CONNECT
FOLLOWS,
PUBLIC_CHATS,
PRIVATE_DMS,
GLOBAL,
SEARCH,
WALLET_CONNECT,
}
val COMMON_FEED_TYPES = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
val COMMON_FEED_TYPES =
setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
class Relay(
val url: String,
val read: Boolean = true,
val write: Boolean = true,
val activeTypes: Set<FeedType> = FeedType.values().toSet()
val activeTypes: Set<FeedType> = FeedType.values().toSet(),
) {
val brief = RelayBriefInfoCache.get(url)
@@ -94,10 +121,11 @@ class Relay(
if (socket != null) return
try {
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url.trim())
.build()
val request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url.trim())
.build()
socket = httpClient.newWebSocket(request, RelayListener(onConnected))
} catch (e: Exception) {
@@ -111,13 +139,17 @@ class Relay(
}
inner class RelayListener(val onConnected: (Relay) -> Unit) : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
checkNotInMainThread()
Log.d("Relay", "Connect onOpen $url $socket")
markConnectionAsReady(
pingInMs = response.receivedResponseAtMillis - response.sentRequestAtMillis,
usingCompression = response.headers.get("Sec-WebSocket-Extensions")?.contains("permessage-deflate") ?: false
usingCompression =
response.headers.get("Sec-WebSocket-Extensions")?.contains("permessage-deflate") ?: false,
)
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
@@ -126,7 +158,10 @@ class Relay(
listeners.forEach { it.onRelayStateChange(this@Relay, StateType.CONNECT, null) }
}
override fun onMessage(webSocket: WebSocket, text: String) {
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
checkNotInMainThread()
eventDownloadCounterInBytes += text.bytesUsedInMemory()
@@ -141,7 +176,11 @@ class Relay(
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
checkNotInMainThread()
Log.w("Relay", "Relay onClosing $url: $reason")
@@ -150,12 +189,16 @@ class Relay(
it.onRelayStateChange(
this@Relay,
StateType.DISCONNECTING,
null
null,
)
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
override fun onClosed(
webSocket: WebSocket,
code: Int,
reason: String,
) {
checkNotInMainThread()
markConnectionAsClosed()
@@ -165,7 +208,11 @@ class Relay(
listeners.forEach { it.onRelayStateChange(this@Relay, StateType.DISCONNECT, null) }
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
checkNotInMainThread()
errorCounter++
@@ -177,13 +224,20 @@ class Relay(
Log.w("Relay", "Relay onFailure $url, ${response?.message} $response")
t.printStackTrace()
listeners.forEach {
it.onError(this@Relay, "", Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t))
it.onError(
this@Relay,
"",
Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t),
)
}
}
}
fun markConnectionAsReady(pingInMs: Long, usingCompression: Boolean) {
this.afterEOSEPerSubscription.clear()
fun markConnectionAsReady(
pingInMs: Long,
usingCompression: Boolean,
) {
this.resetEOSEStatuses()
this.isReady = true
this.pingInMs = pingInMs
this.usingCompression = usingCompression
@@ -193,7 +247,7 @@ class Relay(
this.socket = null
this.isReady = false
this.usingCompression = false
this.afterEOSEPerSubscription.clear()
this.resetEOSEStatuses()
this.closingTimeInSeconds = TimeUtils.now()
}
@@ -207,57 +261,66 @@ class Relay(
// Log.w("Relay", "Relay onEVENT ${event.kind} $url, $subscriptionId ${msgArray.get(2)}")
listeners.forEach {
it.onEvent(this@Relay, subscriptionId, event, afterEOSEPerSubscription[subscriptionId] == true)
it.onEvent(
this@Relay,
subscriptionId,
event,
afterEOSEPerSubscription[subscriptionId] == true,
)
}
}
"EOSE" -> listeners.forEach {
val subscriptionId = msgArray.get(1).asText()
"EOSE" ->
listeners.forEach {
val subscriptionId = msgArray.get(1).asText()
afterEOSEPerSubscription[subscriptionId] = true
// Log.w("Relay", "Relay onEOSE $url $subscriptionId")
it.onRelayStateChange(this@Relay, StateType.EOSE, subscriptionId)
}
"NOTICE" -> listeners.forEach {
val message = msgArray.get(1).asText()
Log.w("Relay", "Relay onNotice $url, $message")
afterEOSEPerSubscription[subscriptionId] = true
// Log.w("Relay", "Relay onEOSE $url $subscriptionId")
it.onRelayStateChange(this@Relay, StateType.EOSE, subscriptionId)
}
"NOTICE" ->
listeners.forEach {
val message = msgArray.get(1).asText()
Log.w("Relay", "Relay onNotice $url, $message")
it.onError(this@Relay, message, Error("Relay sent notice: $message"))
}
"OK" -> listeners.forEach {
val eventId = msgArray[1].asText()
val success = msgArray[2].asBoolean()
val message = if (msgArray.size() > 2) msgArray[3].asText() else ""
it.onError(this@Relay, message, Error("Relay sent notice: $message"))
}
"OK" ->
listeners.forEach {
val eventId = msgArray[1].asText()
val success = msgArray[2].asBoolean()
val message = if (msgArray.size() > 2) msgArray[3].asText() else ""
if (authResponse.containsKey(eventId)) {
val wasAlreadyAuthenticated = authResponse.get(eventId)
authResponse.put(eventId, success)
if (wasAlreadyAuthenticated != true && success) {
renewFilters()
if (authResponse.containsKey(eventId)) {
val wasAlreadyAuthenticated = authResponse.get(eventId)
authResponse.put(eventId, success)
if (wasAlreadyAuthenticated != true && success) {
renewFilters()
}
}
}
Log.w("Relay", "Relay on OK $url, $eventId, $success, $message")
it.onSendResponse(this@Relay, eventId, success, message)
}
"AUTH" -> listeners.forEach {
// Log.w("Relay", "Relay onAuth $url, ${msg[1].asString}")
it.onAuth(this@Relay, msgArray[1].asText())
}
"NOTIFY" -> listeners.forEach {
// Log.w("Relay", "Relay onNotify $url, ${msg[1].asString}")
it.onNotify(this@Relay, msgArray[1].asText())
}
"CLOSED" -> listeners.forEach {
Log.w("Relay", "Relay onClosed $url, $newMessage")
}
else -> listeners.forEach {
Log.w("Relay", "Unsupported message: $newMessage")
it.onError(
this@Relay,
"",
Error("Unknown type $type on channel. Msg was $newMessage")
)
}
Log.w("Relay", "Relay on OK $url, $eventId, $success, $message")
it.onSendResponse(this@Relay, eventId, success, message)
}
"AUTH" ->
listeners.forEach {
// Log.w("Relay", "Relay onAuth $url, ${msg[1].asString}")
it.onAuth(this@Relay, msgArray[1].asText())
}
"NOTIFY" ->
listeners.forEach {
// Log.w("Relay", "Relay onNotify $url, ${msg[1].asString}")
it.onNotify(this@Relay, msgArray[1].asText())
}
"CLOSED" -> listeners.forEach { Log.w("Relay", "Relay onClosed $url, $newMessage") }
else ->
listeners.forEach {
Log.w("Relay", "Unsupported message: $newMessage")
it.onError(
this@Relay,
"",
Error("Unknown type $type on channel. Msg was $newMessage"),
)
}
}
}
@@ -270,7 +333,11 @@ class Relay(
socket = null
isReady = false
usingCompression = false
afterEOSEPerSubscription.clear()
resetEOSEStatuses()
}
fun resetEOSEStatuses() {
afterEOSEPerSubscription = LinkedHashMap(afterEOSEPerSubscription.size)
}
fun sendFilter(requestId: String) {
@@ -279,16 +346,26 @@ class Relay(
if (read) {
if (isConnected()) {
if (isReady) {
val filters = Client.getSubscriptionFilters(requestId).filter { activeTypes.intersect(it.types).isNotEmpty() }
val filters =
Client.getSubscriptionFilters(requestId).filter { filter ->
activeTypes.any { it in filter.types }
}
if (filters.isNotEmpty()) {
val request =
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson(url) }}]"""
filters.joinToStringLimited(
separator = ",",
limit = 20,
prefix = """["REQ","$requestId",""",
postfix = "]",
) {
it.filter.toJson(url)
}
// Log.d("Relay", "onFilterSent $url $requestId $request")
socket?.send(request)
eventUploadCounterInBytes += request.bytesUsedInMemory()
afterEOSEPerSubscription.clear()
resetEOSEStatuses()
}
}
} else {
@@ -301,7 +378,34 @@ class Relay(
}
}
fun sendFilterOnlyIfDisconnected() {
fun <T> Iterable<T>.joinToStringLimited(
separator: CharSequence = ", ",
prefix: CharSequence = "",
postfix: CharSequence = "",
limit: Int = -1,
transform: ((T) -> CharSequence)? = null,
): String {
val buffer = StringBuilder()
buffer.append(prefix)
var count = 0
for (element in this) {
if (limit < 0 || count <= limit) {
if (++count > 1) buffer.append(separator)
when {
transform != null -> buffer.append(transform(element))
element is CharSequence? -> buffer.append(element)
element is Char -> buffer.append(element)
else -> buffer.append(element.toString())
}
} else {
break
}
}
buffer.append(postfix)
return buffer.toString()
}
fun sendFilterOnlyIfDisconnected(subscriptionId: String) {
checkNotInMainThread()
if (socket == null) {
@@ -315,9 +419,7 @@ class Relay(
fun renewFilters() {
// Force update all filters after AUTH.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
}
Client.allSubscriptions().forEach { sendFilter(requestId = it) }
}
fun send(signedEvent: EventInterface) {
@@ -346,9 +448,7 @@ class Relay(
eventUploadCounterInBytes += event.bytesUsedInMemory()
// Sends everything.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
}
Client.allSubscriptions().forEach { sendFilter(requestId = it) }
}
}
}
@@ -359,7 +459,7 @@ class Relay(
checkNotInMainThread()
val msg = """["CLOSE","$subscriptionId"]"""
Log.d("Relay", "Close Subscription $url $msg")
// Log.d("Relay", "Close Subscription $url $msg")
socket?.send(msg)
}
@@ -381,31 +481,51 @@ class Relay(
DISCONNECT,
// End Of Stored Events
EOSE
EOSE,
}
interface Listener {
/**
* A new message was received
*/
fun onEvent(relay: Relay, subscriptionId: String, event: Event, afterEOSE: Boolean)
/** A new message was received */
fun onEvent(
relay: Relay,
subscriptionId: String,
event: Event,
afterEOSE: Boolean,
)
fun onError(relay: Relay, subscriptionId: String, error: Error)
fun onError(
relay: Relay,
subscriptionId: String,
error: Error,
)
fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String)
fun onSendResponse(
relay: Relay,
eventId: String,
success: Boolean,
message: String,
)
fun onAuth(relay: Relay, challenge: String)
fun onAuth(
relay: Relay,
challenge: String,
)
/**
* Connected to or disconnected from a relay
*
* @param type is 0 for disconnect and 1 for connect
*/
fun onRelayStateChange(relay: Relay, type: StateType, channel: String?)
fun onRelayStateChange(
relay: Relay,
type: StateType,
channel: String?,
)
/**
* Relay sent an invoice
*/
fun onNotify(relay: Relay, description: String)
/** Relay sent an invoice */
fun onNotify(
relay: Relay,
description: 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.relays
import androidx.compose.runtime.Immutable
@@ -17,8 +37,9 @@ object RelayPool : Relay.Listener {
private var listeners = setOf<Listener>()
// Backing property to avoid flow emissions from other classes
private var _lastStatus = RelayPoolStatus(0, 0)
private val _statusFlow = MutableSharedFlow<RelayPoolStatus>(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private var lastStatus = RelayPoolStatus(0, 0)
private val _statusFlow =
MutableSharedFlow<RelayPoolStatus>(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val statusFlow: SharedFlow<RelayPoolStatus> = _statusFlow.asSharedFlow()
fun availableRelays(): Int {
@@ -30,7 +51,7 @@ object RelayPool : Relay.Listener {
}
fun getRelay(url: String): Relay? {
return relays.firstOrNull() { it.url == url }
return relays.firstOrNull { it.url == url }
}
fun getRelays(url: String): List<Relay> {
@@ -60,14 +81,15 @@ object RelayPool : Relay.Listener {
relays.forEach { it.sendFilter(subscriptionId) }
}
fun sendFilterOnlyIfDisconnected() {
relays.forEach { it.sendFilterOnlyIfDisconnected() }
fun sendFilterOnlyIfDisconnected(subscriptionId: String) {
relays.forEach { it.sendFilterOnlyIfDisconnected(subscriptionId) }
}
fun sendToSelectedRelays(list: List<Relay>, signedEvent: EventInterface) {
list.forEach { relay ->
relays.filter { it.url == relay.url }.forEach { it.send(signedEvent) }
}
fun sendToSelectedRelays(
list: List<Relay>,
signedEvent: EventInterface,
) {
list.forEach { relay -> relays.filter { it.url == relay.url }.forEach { it.send(signedEvent) } }
}
fun send(signedEvent: EventInterface) {
@@ -103,56 +125,108 @@ object RelayPool : Relay.Listener {
}
interface Listener {
fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean)
fun onEvent(
event: Event,
subscriptionId: String,
relay: Relay,
afterEOSE: Boolean,
)
fun onError(error: Error, subscriptionId: String, relay: Relay)
fun onError(
error: Error,
subscriptionId: String,
relay: Relay,
)
fun onRelayStateChange(type: Relay.StateType, relay: Relay, channel: String?)
fun onRelayStateChange(
type: Relay.StateType,
relay: Relay,
channel: String?,
)
fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay)
fun onSendResponse(
eventId: String,
success: Boolean,
message: String,
relay: Relay,
)
fun onAuth(relay: Relay, challenge: String)
fun onAuth(
relay: Relay,
challenge: String,
)
fun onNotify(relay: Relay, description: String)
fun onNotify(
relay: Relay,
description: String,
)
}
override fun onEvent(relay: Relay, subscriptionId: String, event: Event, afterEOSE: Boolean) {
override fun onEvent(
relay: Relay,
subscriptionId: String,
event: Event,
afterEOSE: Boolean,
) {
listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) }
}
override fun onError(relay: Relay, subscriptionId: String, error: Error) {
override fun onError(
relay: Relay,
subscriptionId: String,
error: Error,
) {
listeners.forEach { it.onError(error, subscriptionId, relay) }
updateStatus()
}
override fun onRelayStateChange(relay: Relay, type: Relay.StateType, channel: String?) {
override fun onRelayStateChange(
relay: Relay,
type: Relay.StateType,
channel: String?,
) {
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
if (type != Relay.StateType.EOSE) {
updateStatus()
}
}
override fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String) {
override fun onSendResponse(
relay: Relay,
eventId: String,
success: Boolean,
message: String,
) {
listeners.forEach { it.onSendResponse(eventId, success, message, relay) }
}
override fun onAuth(relay: Relay, challenge: String) {
override fun onAuth(
relay: Relay,
challenge: String,
) {
listeners.forEach { it.onAuth(relay, challenge) }
}
override fun onNotify(relay: Relay, description: String) {
override fun onNotify(
relay: Relay,
description: String,
) {
listeners.forEach { it.onNotify(relay, description) }
}
private fun updateStatus() {
val connected = connectedRelays()
val available = availableRelays()
if (_lastStatus.connected != connected || _lastStatus.available != available) {
_lastStatus = RelayPoolStatus(connected, available)
_statusFlow.tryEmit(_lastStatus)
if (lastStatus.connected != connected || lastStatus.available != available) {
lastStatus = RelayPoolStatus(connected, available)
_statusFlow.tryEmit(lastStatus)
}
}
}
@Immutable
data class RelayPoolStatus(val connected: Int, val available: Int, val isConnected: Boolean = connected > 0)
data class RelayPoolStatus(
val connected: Int,
val available: Int,
val isConnected: Boolean = connected > 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.relays
import com.fasterxml.jackson.databind.JsonNode
@@ -6,11 +26,14 @@ import java.util.UUID
data class Subscription(
val id: String = UUID.randomUUID().toString().substring(0, 4),
val onEOSE: ((Long, String) -> Unit)? = null
val onEOSE: ((Long, String) -> Unit)? = null,
) {
var typedFilters: List<TypedFilter>? = null // Inactive when null
fun updateEOSE(time: Long, relay: String) {
fun updateEOSE(
time: Long,
relay: String,
) {
onEOSE?.let { it(time, relay) }
}
@@ -27,10 +50,8 @@ data class Subscription(
put(
"typedFilters",
factory.arrayNode(filters.size).apply {
filters.forEach { filter ->
add(filter.toJsonObject())
}
}
filters.forEach { filter -> add(filter.toJsonObject()) }
},
)
}
}

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