mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
253 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9c48d4039 | ||
|
|
9479e86b66 | ||
|
|
bb7ec1ee4b | ||
|
|
7a213545a2 | ||
|
|
48f27f656d | ||
|
|
47176421e0 | ||
|
|
9c84a538d6 | ||
|
|
8bc1c2ec68 | ||
|
|
b94c1e2792 | ||
|
|
74f0393706 | ||
|
|
0ec4b9b59f | ||
|
|
1592d55db5 | ||
|
|
629c8b8167 | ||
|
|
9d8f70e04b | ||
|
|
f75104e616 | ||
|
|
c9d59fd5ac | ||
|
|
d41eb4ce31 | ||
|
|
60e87d943a | ||
|
|
1deed59d5b | ||
|
|
bbc4ec2625 | ||
|
|
5109b65e37 | ||
|
|
c0a9ce8e21 | ||
|
|
bdff8c6c0d | ||
|
|
a3ac8a445d | ||
|
|
84d4d68116 | ||
|
|
27248bff17 | ||
|
|
dc9a9f288b | ||
|
|
88662a4e55 | ||
|
|
9d269f7cba | ||
|
|
04fe439e0b | ||
|
|
72503b9061 | ||
|
|
f7ab963db9 | ||
|
|
f97128e11c | ||
|
|
e2d4107297 | ||
|
|
18709a1b22 | ||
|
|
ddfe2abb42 | ||
|
|
0cf82f7edd | ||
|
|
d1c40efd90 | ||
|
|
68e90ce3ed | ||
|
|
95e19cf681 | ||
|
|
3298fd4493 | ||
|
|
248bdffd50 | ||
|
|
cce7a20918 | ||
|
|
845c99b798 | ||
|
|
b13b832696 | ||
|
|
eb6e235f1b | ||
|
|
36c49b2171 | ||
|
|
5cd3096221 | ||
|
|
5bdc59d0e3 | ||
|
|
be43f2025f | ||
|
|
0bb39f91b9 | ||
|
|
63a7b44264 | ||
|
|
b5341f67e0 | ||
|
|
3a2045eccc | ||
|
|
c78239a6b7 | ||
|
|
4be4306540 | ||
|
|
48887f23c1 | ||
|
|
103809d35b | ||
|
|
9d92976eb9 | ||
|
|
d3d19b497b | ||
|
|
316c32f076 | ||
|
|
6f27201244 | ||
|
|
e56377f8c3 | ||
|
|
44e04864ac | ||
|
|
eea727570c | ||
|
|
54155a3c30 | ||
|
|
34d373c293 | ||
|
|
d7563032c4 | ||
|
|
ed75489975 | ||
|
|
145b0d8f0a | ||
|
|
eae07e2fe0 | ||
|
|
6b60b1434f | ||
|
|
3ff4db264a | ||
|
|
21f042fa95 | ||
|
|
e26bbc2e61 | ||
|
|
5135026e6a | ||
|
|
a72f5ae01f | ||
|
|
f2ed2d94c4 | ||
|
|
75d703ac0a | ||
|
|
277bf0a603 | ||
|
|
1ac990b67c | ||
|
|
e59f676d47 | ||
|
|
312d82fdf6 | ||
|
|
08659738bd | ||
|
|
f1b0958903 | ||
|
|
998aa2aed1 | ||
|
|
370a18f716 | ||
|
|
6638defbb9 | ||
|
|
d88eb014b7 | ||
|
|
14e9a91d40 | ||
|
|
3ed45f282c | ||
|
|
2ff090dac1 | ||
|
|
281d86b4bf | ||
|
|
504e55a93f | ||
|
|
c7b2c20119 | ||
|
|
3e102c064b | ||
|
|
e0ec8a741c | ||
|
|
f1388b46d5 | ||
|
|
0f24e458d8 | ||
|
|
b8f3625954 | ||
|
|
0babc46f7c | ||
|
|
6d368148cc | ||
|
|
3241f0ada1 | ||
|
|
49ad972af8 | ||
|
|
e061575454 | ||
|
|
694642b42b | ||
|
|
f4684ed078 | ||
|
|
db12ff0532 | ||
|
|
13d9c1ab10 | ||
|
|
ac8f0e8600 | ||
|
|
21472daab5 | ||
|
|
c6a00386c8 | ||
|
|
9e3aa31c25 | ||
|
|
47987fac0f | ||
|
|
46c782cf37 | ||
|
|
fd67f4875d | ||
|
|
4e2ff591e6 | ||
|
|
0e46145e2d | ||
|
|
383d859544 | ||
|
|
81400eb863 | ||
|
|
422446f53e | ||
|
|
f1e721f323 | ||
|
|
0fc72eb84d | ||
|
|
9234f4b7a8 | ||
|
|
0198d56099 | ||
|
|
8f31c60cd1 | ||
|
|
7cb17954d9 | ||
|
|
baf35b202f | ||
|
|
7b7e3624ac | ||
|
|
d27b9ae5a8 | ||
|
|
4c48463091 | ||
|
|
eb08d485e1 | ||
|
|
6e99e6f4a2 | ||
|
|
d269e9e597 | ||
|
|
f8e7dd78d9 | ||
|
|
0be1f89368 | ||
|
|
5c7e9258c1 | ||
|
|
f877b6ff68 | ||
|
|
f5f43c5094 | ||
|
|
ec867ae8a2 | ||
|
|
de61f7f383 | ||
|
|
ed6c1e62e4 | ||
|
|
620b2bfa9f | ||
|
|
6715d48a9d | ||
|
|
4291a58f85 | ||
|
|
00cffc8b6b | ||
|
|
7143d78663 | ||
|
|
eaaa9a6446 | ||
|
|
41270c3a98 | ||
|
|
a08a052b30 | ||
|
|
9403f12d2d | ||
|
|
89fb83cd9a | ||
|
|
2355a95202 | ||
|
|
53a320b4dc | ||
|
|
2de3d19a34 | ||
|
|
57430c4366 | ||
|
|
941ed77f22 | ||
|
|
005b082873 | ||
|
|
9f219e31ab | ||
|
|
9101fb1e9e | ||
|
|
c215beeb8e | ||
|
|
20b730318f | ||
|
|
abef3d13f2 | ||
|
|
77eb066362 | ||
|
|
ec4981852b | ||
|
|
56c2c60c58 | ||
|
|
48aa9f950d | ||
|
|
37ae06347f | ||
|
|
78616c07b1 | ||
|
|
73eff8a6c3 | ||
|
|
4afb0027fb | ||
|
|
1cedc8a6c8 | ||
|
|
216ee59fae | ||
|
|
adc4599adf | ||
|
|
8ed960530c | ||
|
|
8f13a76870 | ||
|
|
adaab4ced8 | ||
|
|
5a2e149fa0 | ||
|
|
560c1f797c | ||
|
|
2d5be92f34 | ||
|
|
e9285fdac0 | ||
|
|
4f12c127af | ||
|
|
be7522382f | ||
|
|
987fb02be1 | ||
|
|
bbd8a34f44 | ||
|
|
720ebfd0ea | ||
|
|
159228972c | ||
|
|
2a65944eeb | ||
|
|
e7902c4565 | ||
|
|
f12588ef7c | ||
|
|
5fc197a40b | ||
|
|
64608f771e | ||
|
|
a9cebaf3d9 | ||
|
|
6c33dae498 | ||
|
|
e42d2dca04 | ||
|
|
35cd9cb6c5 | ||
|
|
2ce16325a1 | ||
|
|
275aeb7833 | ||
|
|
34737be5eb | ||
|
|
6fb5c873cc | ||
|
|
e0ffba5295 | ||
|
|
82f03e0769 | ||
|
|
b223cc4028 | ||
|
|
550a9abdbb | ||
|
|
a121d1b301 | ||
|
|
e794ff44a1 | ||
|
|
a22b6db130 | ||
|
|
1f7fc236db | ||
|
|
1edaea7905 | ||
|
|
e8cae710c7 | ||
|
|
489d92f9bb | ||
|
|
4d4c568afc | ||
|
|
e452c33a98 | ||
|
|
fdfd875580 | ||
|
|
a6e8ee20ae | ||
|
|
065dba2431 | ||
|
|
5f581c2f5e | ||
|
|
f7bfb68e7c | ||
|
|
2371e0678a | ||
|
|
97c78aaa3c | ||
|
|
9026c877ac | ||
|
|
238ade9ccb | ||
|
|
016bd1ed81 | ||
|
|
ed21d4cafb | ||
|
|
ecc0f39f7d | ||
|
|
26846fa91a | ||
|
|
fe3a0568f2 | ||
|
|
56252d33f1 | ||
|
|
fccfb2aaba | ||
|
|
7b55b69c12 | ||
|
|
23fad2a818 | ||
|
|
2dc23b601a | ||
|
|
504980d263 | ||
|
|
7cf7c9f082 | ||
|
|
dd8904de15 | ||
|
|
d1c43f8147 | ||
|
|
68506fd6af | ||
|
|
b795ca06d1 | ||
|
|
8695acb742 | ||
|
|
ed491f5e3a | ||
|
|
3a76c2a2de | ||
|
|
91b3b9e0e9 | ||
|
|
fb280650a5 | ||
|
|
e4baebf2f9 | ||
|
|
1a33da2537 | ||
|
|
3734ee58ea | ||
|
|
9554b36f80 | ||
|
|
5225c6ad07 | ||
|
|
c7be0e4a95 | ||
|
|
d4f060d509 | ||
|
|
9ba9ddee50 | ||
|
|
7bbdd5e877 | ||
|
|
6cc65ce84a |
14
.editorconfig
Normal file
14
.editorconfig
Normal 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
|
||||
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
19
.github/workflows/create-release.yml
vendored
19
.github/workflows/create-release.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/crowdin.yml
vendored
4
.github/workflows/crowdin.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -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
|
||||
|
||||
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.9.10" />
|
||||
<option name="version" value="1.9.22" />
|
||||
</component>
|
||||
</project>
|
||||
177
README.md
177
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
<img align="right" src="./docs/screenshots/home.png" data-canonical-src="./docs/screenshots/home.png" width="350px"/>
|
||||
|
||||
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
|
||||
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
|
||||
|
||||
[<img src="./docs/design/obtainium.png"
|
||||
alt="Get it on Obtaininum"
|
||||
@@ -18,91 +18,94 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
|
||||
# Current Features
|
||||
|
||||
- [x] Event Builders / WebSocket Subscriptions (NIP-01)
|
||||
- [x] Home Feed
|
||||
- [x] Notifications Feed
|
||||
- [x] Global Feed
|
||||
- [x] Replies and mentions (NIP-10)
|
||||
- [x] Reactions (NIP-25)
|
||||
- [x] Reposts (NIP-18)
|
||||
- [x] Image/Video/Url/LnInvoice Previews
|
||||
- [x] View Threads
|
||||
- [x] Events / Relay Subscriptions (NIP-01)
|
||||
- [x] Follow List (NIP-02)
|
||||
- [ ] OpenTimestamps Attestations (NIP-03)
|
||||
- [x] Private Messages (NIP-04)
|
||||
- [x] User Profiles (edit/follow/unfollow - NIP-02)
|
||||
- [x] Public Chats (NIP-28)
|
||||
- [x] Bech Encoding support (NIP-19)
|
||||
- [x] Notification Dots
|
||||
- [x] Reporting and Hide User capability (NIP-56)
|
||||
- [x] Automatic Translations
|
||||
- [x] Relay Sets (home, dms, public chats, global)
|
||||
- [x] User/Note Tagging (NIP-08, NIP-10)
|
||||
- [x] Lightning Tips
|
||||
- [x] Zaps (private, public, anon, non-zap) (NIP-57)
|
||||
- [x] URI Support (NIP-21)
|
||||
- [x] Created_at Limits (NIP-22)
|
||||
- [x] DNS Address (NIP-05)
|
||||
- [ ] Mnemonic seed phrase (NIP-06)
|
||||
- [ ] WebBrowser Signer (NIP-07, Not applicable)
|
||||
- [x] Old-style mentions (NIP-08)
|
||||
- [x] Event Deletion (NIP-09)
|
||||
- [x] Nostr Address (NIP-05)
|
||||
- [x] Replies, mentions, Threads, and Notifications (NIP-10)
|
||||
- [x] Relay Information Document (NIP-11)
|
||||
- [x] Generic Tag Queries (NIP-12)
|
||||
- [x] Proof of Work Display (NIP-13)
|
||||
- [ ] Proof of Work Calculations (NIP-13)
|
||||
- [x] Events with a Subject (NIP-14)
|
||||
- [ ] Marketplace (NIP-15)
|
||||
- [x] Event Treatment (NIP-16)
|
||||
- [x] Image/Video/Url/LnInvoice Previews
|
||||
- [x] Reposts, Quotes, Generic Reposts (NIP-18)
|
||||
- [x] Bech Encoding support (NIP-19)
|
||||
- [x] Command Results (NIP-20)
|
||||
- [x] URI Support (NIP-21)
|
||||
- [x] Long-form Content (NIP-23)
|
||||
- [x] User Profile Fields / Relay list (NIP-24)
|
||||
- [x] Reactions (NIP-25)
|
||||
- [ ] Delegated Event Signing (NIP-26, Will not implement)
|
||||
- [x] Text Note References (NIP-27)
|
||||
- [x] Public Chats (NIP-28)
|
||||
- [x] Custom Emoji (NIP-30)
|
||||
- [x] Event kind summaries (NIP-31)
|
||||
- [ ] Labeling (NIP-32)
|
||||
- [x] Parameterized Replaceable Events (NIP-33)
|
||||
- [x] Online Relay Search (NIP-50)
|
||||
- [x] Internationalization
|
||||
- [x] Badges (NIP-58)
|
||||
- [x] Hashtag Following and Custom Hashtags
|
||||
- [x] Polls (NIP-69)
|
||||
- [x] Verifiable static content in URLs (NIP-94)
|
||||
- [x] Login with QR
|
||||
- [x] Sensitive Content (NIP-36)
|
||||
- [x] User Status Event (NIP-38)
|
||||
- [x] External Identities (NIP-39)
|
||||
- [x] Expiration Support (NIP-40)
|
||||
- [x] Relay Authentication (NIP-42)
|
||||
- [ ] Event Counts (NIP-45, Will not implement)
|
||||
- [ ] Nostr Connect (NIP-46)
|
||||
- [x] Wallet Connect API (NIP-47)
|
||||
- [x] Accessible uploads
|
||||
- [ ] Proxy Tags (NIP-48, Not applicable)
|
||||
- [x] Private key encryption for import/export (NIP-49)
|
||||
- [x] Online Relay Search (NIP-50)
|
||||
- [x] Lists (NIP-51)
|
||||
- [ ] Calendar Events (NIP-52)
|
||||
- [x] Live Activities & Live Chats (NIP-53)
|
||||
- [x] Inline Metadata (NIP-55 - Draft)
|
||||
- [x] Reporting (NIP-56)
|
||||
- [x] Lightning Tips
|
||||
- [x] Zaps (NIP-57)
|
||||
- [x] Private Zaps
|
||||
- [x] Zap Splits (NIP-57)
|
||||
- [x] Gift Wraps & Seals (NIP-59)
|
||||
- [x] Zapraiser (NIP-TBD)
|
||||
- [x] Badges (NIP-58)
|
||||
- [ ] Relay List Metadata (NIP-65)
|
||||
- [x] Polls (NIP-69)
|
||||
- [x] Moderated Communities (NIP-72)
|
||||
- [ ] Zap Goals (NIP-75)
|
||||
- [ ] Arbitrary Custom App Data (NIP-78)
|
||||
- [x] Highlights (NIP-84)
|
||||
- [x] Recommended Application Handlers (NIP-89)
|
||||
- [ ] Data Vending Machine (NIP-90)
|
||||
- [x] Inline Metadata (NIP-92)
|
||||
- [x] Verifiable file URLs (NIP-94)
|
||||
- [x] Binary Blobs (NIP-95)
|
||||
- [x] HTTP File Storage Integration (NIP-96 Draft)
|
||||
- [x] HTTP Auth (NIP-98)
|
||||
- [x] Classifieds (NIP-99)
|
||||
- [x] Private Messages and Small Groups (NIP-24/Draft)
|
||||
- [x] Versioned Encrypted Payloads (NIP-44/Draft)
|
||||
- [x] Audio Tracks (zapstr.live) (Kind:31337)
|
||||
- [x] Push Notifications (Google and Unified Push)
|
||||
- [x] In-Device Automatic Translations
|
||||
- [x] Hashtag Following and Custom Hashtags
|
||||
- [x] Login with QR
|
||||
- [x] Bounty support (nostrbounties.com)
|
||||
- [x] De-googled F-Droid flavor
|
||||
- [x] External Identity Support (NIP-39)
|
||||
- [x] Multiple Accounts
|
||||
- [x] Markdown Support
|
||||
- [x] Relay Authentication (NIP-42)
|
||||
- [x] Content stored in relays themselves (NIP-95)
|
||||
- [x] Custom Emoji (NIP-30)
|
||||
- [x] Zap Forwarding (NIP-57)
|
||||
- [x] Text Note References (NIP-27)
|
||||
- [x] Audio Tracks (zapstr.live) (Kind:31337)
|
||||
- [x] Push Notifications (Zaps and Messages)
|
||||
- [x] Generic Tags (NIP-12)
|
||||
- [x] Sensitive Content (NIP-36)
|
||||
- [x] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
|
||||
- [x] Recommended Application Handlers (NIP-89)
|
||||
- [x] Events with a Subject (NIP-14)
|
||||
- [x] Generic Reposts (kind:16)
|
||||
- [x] Live Activities & Live Chats (NIP-53)
|
||||
- [x] Relay Pages (NIP-11)
|
||||
- [x] HTTP Auth (NIP-98)
|
||||
- [x] Zapraiser (NIP-TBD)
|
||||
- [x] Moderated Communities (NIP-72)
|
||||
- [x] Emoji Packs (Kind:30030)
|
||||
- [x] Personal Emoji Lists (Kind:10030)
|
||||
- [x] Classifieds (Kind: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
|
||||
|
||||
## Overall Architecture
|
||||
## Overall Architecture
|
||||
|
||||
This is a native Android app made with Kotlin and Jetpack Compose.
|
||||
The app uses a modified version of the [nostrpostrlib](https://github.com/Giszmo/NostrPostr/tree/master/nostrpostrlib) to talk to Nostr relays.
|
||||
@@ -110,13 +113,13 @@ The overall architecture consists of the UI, which uses the usual State/ViewMode
|
||||
and the model/repository layer, which keeps all Nostr objects in memory, in a full OO graph.
|
||||
|
||||
The repository layer stores Nostr Events as Notes and Users separately. Those classes use LiveData objects to
|
||||
allow the UI and other parts of the app to subscribe to each individual Note/User and receive updates when they happen.
|
||||
allow the UI and other parts of the app to subscribe to each Note/User and receive updates when they happen.
|
||||
They are also responsible for updating viewModels when needed. Filters react to changes in the screen. As the user
|
||||
sees different Events, the Datasource classes are used to receive more information about those particular Events.
|
||||
|
||||
Most of the UI is reactive to changes in the repository classes. The service layer assembles Nostr filters for each need of the app,
|
||||
receives the data from the Relay, and sends it to the repository. Connection with relays is never closed during the use of the app.
|
||||
The UI receives a notification that objects were updated. Instances of User and Notes are mutable directly.
|
||||
The UI receives a notification that objects have been updated. Instances of User and Notes are mutable directly.
|
||||
There will never be two Notes with the same ID or two User instances with the same pubkey.
|
||||
|
||||
Lastly, the user's account information (priv key/pub key) is stored in the Android KeyStore for security.
|
||||
@@ -128,7 +131,7 @@ Make sure to have the following pre-requisites installed:
|
||||
2. Android Studio
|
||||
3. Android 8.0+ Phone or Emulation setup
|
||||
|
||||
Fork and clone this repository and import into Android Studio
|
||||
Fork and clone this repository and import it into Android Studio
|
||||
```bash
|
||||
git clone https://github.com/vitorpamplona/amethyst.git
|
||||
```
|
||||
@@ -149,8 +152,8 @@ Build the app:
|
||||
|
||||
## Linting
|
||||
```bash
|
||||
./gradlew ktlintCheck
|
||||
./gradlew ktlintFormat
|
||||
./gradlew spotlessCheck
|
||||
./gradlew spotlessApply
|
||||
```
|
||||
|
||||
## Installing on device
|
||||
@@ -165,6 +168,18 @@ For the Play build:
|
||||
./gradlew installPlayDebug
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) here are also very welcome.
|
||||
|
||||
Translations can be provided via [Crowdin](https://crowdin.com/project/amethyst-social)
|
||||
|
||||
You can also send patches through Nostr using [GitStr](https://github.com/fiatjaf/gitstr) to [this nostr address](https://patch34.pages.dev/naddr1qqyxzmt9w358jum5qyg8v6t5daezumn0wd68yvfwvdhk6qg7waehxw309ahx7um5wgkhqatz9emk2mrvdaexgetj9ehx2ap0qy2hwumn8ghj7un9d3shjtnwdaehgu3wvfnj7q3qgcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqxpqqqpmej720gac)
|
||||
|
||||
By contributing to this repository, you agree to license your work under the MIT license. Any work
|
||||
contributed where you are not the original author must contain its license header with the original
|
||||
author(s) and source.
|
||||
|
||||
## How to Deploy
|
||||
|
||||
1. Generate a new signing key
|
||||
@@ -182,10 +197,10 @@ openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.j
|
||||
5. Tag the commit with `v{x.x.x}`
|
||||
6. Let the [Create Release GitHub Action](https://github.com/vitorpamplona/amethyst/actions/workflows/create-release.yml) build a new `aab` file.
|
||||
7. Add your CHANGE LOG to the description of the new release
|
||||
8. Download the `aab` file and upload it to the` PlayStore.
|
||||
8. Download the `aab` file and upload it to the PlayStore.
|
||||
|
||||
# Privacy on Relays & nostr
|
||||
Your internet protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
|
||||
Your internet protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
|
||||
|
||||
The relay also learns which public keys you are requesting, meaning your public key will be tied to your IP address.
|
||||
|
||||
@@ -196,11 +211,11 @@ While the content of direct messages (DMs) is only visible to you and your DM co
|
||||
|
||||
# Visibility & Permanence of Your Content on nostr
|
||||
## Information Visibility ##
|
||||
Content that you share can be shared to other relays.
|
||||
Content that you share can be shared to other relays.
|
||||
Information that you share publicly is visible to anyone reading from relays that have your information. Your information may also be visible to nostr users who do not share relays with you.
|
||||
|
||||
## Information Permanence ##
|
||||
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee edit or deletion of any content once posted.
|
||||
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee edit or deletion of any content once posted.
|
||||
|
||||
# Screenshots
|
||||
|
||||
@@ -208,10 +223,6 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th
|
||||
|-------------------------------------------|----------------------------------------------|-------------------------------------------------|--------------------------------------------------------|
|
||||
|  |  |  |  |
|
||||
|
||||
# Contributing
|
||||
|
||||
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome.
|
||||
|
||||
## Contributors
|
||||
|
||||
<a align="center" href="https://github.com/vitorpamplona/amethyst/graphs/contributors">
|
||||
|
||||
@@ -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 339
|
||||
versionName "0.82.2"
|
||||
versionCode 355
|
||||
versionName "0.84.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -27,7 +26,7 @@ android {
|
||||
'cy-rGB',
|
||||
'da-rDK',
|
||||
'de',
|
||||
'el-rGB',
|
||||
'el-rGR',
|
||||
'en-rGB',
|
||||
'eo',
|
||||
'es',
|
||||
@@ -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,11 @@ android {
|
||||
versionNameSuffix '-DEBUG'
|
||||
resValue "string", "app_name", "@string/app_name_debug"
|
||||
}
|
||||
create("benchmark") {
|
||||
initWith(getByName("release"))
|
||||
profileable true
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions = ["channel"]
|
||||
@@ -134,10 +137,12 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
compose true
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion "1.5.3"
|
||||
// Should match compose version : https://developer.android.com/jetpack/androidx/releases/compose-kotlin
|
||||
kotlinCompilerExtensionVersion "1.5.8"
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
@@ -157,8 +162,9 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation project(path: ':quartz')
|
||||
implementation project(path: ':commons')
|
||||
implementation "androidx.core:core-ktx:$core_ktx_version"
|
||||
implementation 'androidx.activity:activity-compose:1.8.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"
|
||||
|
||||
@@ -177,7 +183,7 @@ dependencies {
|
||||
|
||||
// Adaptive Layout / Two Pane
|
||||
implementation "androidx.compose.material3:material3-window-size-class:${material3_version}"
|
||||
implementation "com.google.accompanist:accompanist-adaptive:0.32.0"
|
||||
implementation 'com.google.accompanist:accompanist-adaptive:0.34.0'
|
||||
|
||||
|
||||
// Lifecycle
|
||||
@@ -187,16 +193,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.6.0'
|
||||
|
||||
// Biometrics
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
|
||||
// Websockets API
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.12'
|
||||
|
||||
// HTML Parsing for Link Preview
|
||||
implementation 'org.jsoup:jsoup:1.17.1'
|
||||
implementation 'org.jsoup:jsoup:1.17.2'
|
||||
|
||||
// Encrypted Key Storage
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
|
||||
@@ -224,7 +230,7 @@ dependencies {
|
||||
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
|
||||
|
||||
// For QR generation
|
||||
implementation 'com.google.zxing:core:3.5.2'
|
||||
implementation 'com.google.zxing:core:3.5.3'
|
||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||
|
||||
// Markdown
|
||||
@@ -247,7 +253,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.2')
|
||||
playImplementation 'com.google.firebase:firebase-messaging-ktx'
|
||||
|
||||
//PushNotifications(FDroid)
|
||||
@@ -271,9 +277,9 @@ dependencies {
|
||||
implementation 'id.zelory:compressor:3.0.1'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'io.mockk:mockk:1.13.8'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha02'
|
||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.0-alpha02'
|
||||
testImplementation 'io.mockk:mockk:1.13.9'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha03'
|
||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.0-alpha03'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
|
||||
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
|
||||
|
||||
@@ -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 it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
@@ -12,6 +32,7 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrC
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -20,21 +41,27 @@ import kotlinx.coroutines.launch
|
||||
import org.unifiedpush.android.connector.MessagingReceiver
|
||||
|
||||
class PushMessageReceiver : MessagingReceiver() {
|
||||
private val TAG = "Amethyst-OSSPushReceiver"
|
||||
companion object {
|
||||
private val TAG = "Amethyst-OSSPushReceiver"
|
||||
}
|
||||
|
||||
private val appContext = Amethyst.instance.applicationContext
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val eventCache = LruCache<String, String>(100)
|
||||
private val pushHandler = PushDistributorHandler
|
||||
|
||||
override fun onMessage(context: Context, message: ByteArray, instance: String) {
|
||||
override fun onMessage(
|
||||
context: Context,
|
||||
message: ByteArray,
|
||||
instance: String,
|
||||
) {
|
||||
val messageStr = String(message)
|
||||
Log.d(TAG, "New message ${message.decodeToString()} for Instance: $instance")
|
||||
scope.launch {
|
||||
try {
|
||||
parseMessage(messageStr)?.let {
|
||||
receiveIfNew(it)
|
||||
}
|
||||
parseMessage(messageStr)?.let { receiveIfNew(it) }
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.d(TAG, "Message could not be parsed: ${e.message}")
|
||||
}
|
||||
}
|
||||
@@ -54,7 +81,11 @@ class PushMessageReceiver : MessagingReceiver() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
|
||||
override fun onNewEndpoint(
|
||||
context: Context,
|
||||
endpoint: String,
|
||||
instance: String,
|
||||
) {
|
||||
Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance")
|
||||
val sanitizedEndpoint = endpoint.dropLast(5)
|
||||
pushHandler.setEndpoint(sanitizedEndpoint)
|
||||
@@ -65,20 +96,29 @@ class PushMessageReceiver : MessagingReceiver() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
val intentData = intent.dataString
|
||||
val intentAction = intent.action.toString()
|
||||
Log.d(TAG, "Intent Data:- $intentData Intent Action: $intentAction")
|
||||
super.onReceive(context, intent)
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(context: Context, instance: String) {
|
||||
override fun onRegistrationFailed(
|
||||
context: Context,
|
||||
instance: String,
|
||||
) {
|
||||
Log.d(TAG, "Registration failed for Instance: $instance")
|
||||
scope.cancel()
|
||||
pushHandler.forceRemoveDistributor(context)
|
||||
}
|
||||
|
||||
override fun onUnregistered(context: Context, instance: String) {
|
||||
override fun onUnregistered(
|
||||
context: Context,
|
||||
instance: String,
|
||||
) {
|
||||
val removedEndpoint = pushHandler.endpoint
|
||||
Log.d(TAG, "Endpoint: $removedEndpoint removed for Instance: $instance")
|
||||
Log.d(TAG, "App is unregistered. ")
|
||||
@@ -87,6 +127,7 @@ class PushMessageReceiver : MessagingReceiver() {
|
||||
}
|
||||
|
||||
fun notificationManager(): NotificationManager {
|
||||
return ContextCompat.getSystemService(appContext, NotificationManager::class.java) as NotificationManager
|
||||
return ContextCompat.getSystemService(appContext, NotificationManager::class.java)
|
||||
as NotificationManager
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
object PushNotificationUtils {
|
||||
var hasInit: Boolean = false
|
||||
private val pushHandler = PushDistributorHandler
|
||||
|
||||
suspend fun init(accounts: List<AccountInfo>) {
|
||||
if (hasInit || pushHandler.savedDistributorExists()) {
|
||||
return
|
||||
@@ -15,6 +37,7 @@ object PushNotificationUtils {
|
||||
RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.d("Amethyst-OSSPushUtils", "Failed to get endpoint.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<queries>
|
||||
<package android:name="org.torproject.android"/>
|
||||
<package android:name="com.greenart7c3.nostrsigner"/>
|
||||
<package android:name="com.greenart7c3.nostrsigner.debug"/>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="nostrsigner" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<!-- Doesn't require a camera -->
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,85 +1,105 @@
|
||||
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 androidx.security.crypto.EncryptedSharedPreferences
|
||||
import coil.ImageLoader
|
||||
import coil.disk.DiskCache
|
||||
import com.vitorpamplona.amethyst.service.playback.VideoCache
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
class Amethyst : Application() {
|
||||
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
applicationIOScope.cancel()
|
||||
}
|
||||
|
||||
val videoCache: VideoCache by lazy {
|
||||
val newCache = VideoCache()
|
||||
newCache.initFileCache(this)
|
||||
newCache
|
||||
}
|
||||
|
||||
private val imageCache: DiskCache by lazy {
|
||||
DiskCache.Builder()
|
||||
.directory(applicationContext.safeCacheDir.resolve("image_cache"))
|
||||
.maxSizePercent(0.2)
|
||||
.maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setThreadPolicy(
|
||||
ThreadPolicy.Builder().detectAll().penaltyLog().build(),
|
||||
)
|
||||
StrictMode.setVmPolicy(
|
||||
VmPolicy.Builder().detectAll().penaltyLog().build(),
|
||||
)
|
||||
}
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val (value, elapsed) =
|
||||
measureTimedValue {
|
||||
// initializes the video cache in a thread
|
||||
videoCache
|
||||
}
|
||||
Log.d("Rendering Metrics", "VideoCache initialized in $elapsed")
|
||||
}
|
||||
}
|
||||
|
||||
fun imageLoaderBuilder(): ImageLoader.Builder {
|
||||
return ImageLoader.Builder(applicationContext).diskCache { imageCache }
|
||||
}
|
||||
|
||||
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences {
|
||||
return EncryptedStorage.preferences(instance, npub)
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: Amethyst
|
||||
private set
|
||||
}
|
||||
}
|
||||
|
||||
internal val Context.safeCacheDir: File
|
||||
get() {
|
||||
val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" }
|
||||
return cacheDir.apply { mkdirs() }
|
||||
}
|
||||
|
||||
@@ -1,30 +1,54 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
object EncryptedStorage {
|
||||
private const val PREFERENCES_NAME = "secret_keeper"
|
||||
class EncryptedStorage {
|
||||
companion object {
|
||||
private const val PREFERENCES_NAME = "secret_keeper"
|
||||
|
||||
// returns the preferences for each account or a global file if null.
|
||||
fun prefsFileName(npub: String? = null): String {
|
||||
return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
|
||||
}
|
||||
// returns the preferences for each account or a global file if null.
|
||||
fun prefsFileName(npub: String? = null): String {
|
||||
return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
|
||||
}
|
||||
|
||||
fun preferences(npub: String? = null): EncryptedSharedPreferences {
|
||||
val context = Amethyst.instance
|
||||
val masterKey: MasterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
fun preferences(
|
||||
applicationContext: Context,
|
||||
npub: String? = null,
|
||||
): EncryptedSharedPreferences {
|
||||
val masterKey: MasterKey =
|
||||
MasterKey.Builder(applicationContext, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
val preferencesName = prefsFileName(npub)
|
||||
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
preferencesName,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
) as EncryptedSharedPreferences
|
||||
return EncryptedSharedPreferences.create(
|
||||
applicationContext,
|
||||
prefsFileName(npub),
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
) as EncryptedSharedPreferences
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -20,7 +40,7 @@ import com.vitorpamplona.amethyst.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.model.parseBooleanType
|
||||
import com.vitorpamplona.amethyst.model.parseConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.parseThemeType
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
@@ -33,6 +53,7 @@ import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.signers.ExternalSignerLauncher
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerExternal
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -51,7 +72,7 @@ private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
|
||||
data class AccountInfo(
|
||||
val npub: String,
|
||||
val hasPrivKey: Boolean,
|
||||
val loggedInWithExternalSigner: Boolean
|
||||
val loggedInWithExternalSigner: Boolean,
|
||||
)
|
||||
|
||||
private object PrefKeys {
|
||||
@@ -97,65 +118,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 +192,28 @@ object LocalPreferences {
|
||||
updateSavedAccounts(accounts)
|
||||
}
|
||||
|
||||
private suspend fun setCurrentAccount(account: Account) = withContext(Dispatchers.IO) {
|
||||
val npub = account.userProfile().pubkeyNpub()
|
||||
val accInfo = AccountInfo(
|
||||
npub,
|
||||
account.isWriteable(),
|
||||
account.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,34 +229,36 @@ object LocalPreferences {
|
||||
checkNotInMainThread()
|
||||
|
||||
return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) {
|
||||
val preferenceFile = if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub"
|
||||
val preferenceFile =
|
||||
if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub"
|
||||
Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
|
||||
} else {
|
||||
return EncryptedStorage.preferences(npub)
|
||||
return Amethyst.instance.encryptedStorage(npub)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the preferences for a given npub, deletes the preferences xml file,
|
||||
* and switches the user to the first account in the list if it exists
|
||||
* Clears the preferences for a given npub, deletes the preferences xml file, and switches the
|
||||
* user to the first account in the list if it exists
|
||||
*
|
||||
* We need to use `commit()` to write changes to disk and release the file
|
||||
* lock so that it can be deleted. If we use `apply()` there is a race
|
||||
* condition and the file will probably not be deleted
|
||||
* We need to use `commit()` to write changes to disk and release the file lock so that it can be
|
||||
* deleted. If we use `apply()` there is a race condition and the file will probably not be
|
||||
* deleted
|
||||
*/
|
||||
@SuppressLint("ApplySharedPref")
|
||||
suspend fun updatePrefsForLogout(accountInfo: AccountInfo) = withContext(Dispatchers.IO) {
|
||||
val userPrefs = encryptedPreferences(accountInfo.npub)
|
||||
userPrefs.edit().clear().commit()
|
||||
removeAccount(accountInfo)
|
||||
deleteUserPreferenceFile(accountInfo.npub)
|
||||
suspend fun updatePrefsForLogout(accountInfo: AccountInfo) =
|
||||
withContext(Dispatchers.IO) {
|
||||
val userPrefs = encryptedPreferences(accountInfo.npub)
|
||||
userPrefs.edit().clear().commit()
|
||||
removeAccount(accountInfo)
|
||||
deleteUserPreferenceFile(accountInfo.npub)
|
||||
|
||||
if (savedAccounts().isEmpty()) {
|
||||
encryptedPreferences().edit().clear().apply()
|
||||
} else if (currentAccount() == accountInfo.npub) {
|
||||
updateCurrentAccount(savedAccounts().elementAt(0).npub)
|
||||
if (savedAccounts().isEmpty()) {
|
||||
updateCurrentAccount(null)
|
||||
} else if (currentAccount() == accountInfo.npub) {
|
||||
updateCurrentAccount(savedAccounts().elementAt(0).npub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePrefsForLogin(account: Account) {
|
||||
setCurrentAccount(account)
|
||||
@@ -244,54 +269,80 @@ object LocalPreferences {
|
||||
return savedAccounts()
|
||||
}
|
||||
|
||||
suspend fun saveToEncryptedStorage(account: Account) = withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
suspend fun saveToEncryptedStorage(account: Account) =
|
||||
withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
|
||||
prefs.edit().apply {
|
||||
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.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 +354,10 @@ object LocalPreferences {
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun saveSharedSettings(sharedSettings: Settings, prefs: SharedPreferences = encryptedPreferences()) {
|
||||
suspend fun saveSharedSettings(
|
||||
sharedSettings: Settings,
|
||||
prefs: SharedPreferences = encryptedPreferences(),
|
||||
) {
|
||||
with(prefs.edit()) {
|
||||
putString(PrefKeys.SHARED_SETTINGS, Event.mapper.writeValueAsString(sharedSettings))
|
||||
apply()
|
||||
@@ -313,11 +367,14 @@ object LocalPreferences {
|
||||
suspend fun loadSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? {
|
||||
with(prefs) {
|
||||
return try {
|
||||
getString(PrefKeys.SHARED_SETTINGS, "{}")?.let {
|
||||
Event.mapper.readValue<Settings>(it)
|
||||
}
|
||||
getString(PrefKeys.SHARED_SETTINGS, "{}")?.let { Event.mapper.readValue<Settings>(it) }
|
||||
} catch (e: Throwable) {
|
||||
Log.w("LocalPreferences", "Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}", e)
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}",
|
||||
e,
|
||||
)
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
@@ -331,39 +388,45 @@ object LocalPreferences {
|
||||
return null
|
||||
}
|
||||
|
||||
val automaticallyShowImages = if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
val automaticallyShowImages =
|
||||
if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
|
||||
val automaticallyStartPlayback = if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
val automaticallyShowUrlPreview = if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
val automaticallyHideNavigationBars = if (contains(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS)) {
|
||||
parseBooleanType(getBoolean(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS, false))
|
||||
} else {
|
||||
BooleanType.ALWAYS
|
||||
}
|
||||
val automaticallyStartPlayback =
|
||||
if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
val automaticallyShowUrlPreview =
|
||||
if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
val automaticallyHideNavigationBars =
|
||||
if (contains(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS)) {
|
||||
parseBooleanType(getBoolean(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS, false))
|
||||
} else {
|
||||
BooleanType.ALWAYS
|
||||
}
|
||||
|
||||
val automaticallyShowProfilePictures = if (contains(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
val automaticallyShowProfilePictures =
|
||||
if (contains(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
|
||||
val themeType = if (contains(PrefKeys.THEME)) {
|
||||
parseThemeType(getInt(PrefKeys.THEME, ThemeType.SYSTEM.screenCode))
|
||||
} else {
|
||||
ThemeType.SYSTEM
|
||||
}
|
||||
val themeType =
|
||||
if (contains(PrefKeys.THEME)) {
|
||||
parseThemeType(getInt(PrefKeys.THEME, ThemeType.SYSTEM.screenCode))
|
||||
} else {
|
||||
ThemeType.SYSTEM
|
||||
}
|
||||
|
||||
return Settings(
|
||||
themeType,
|
||||
@@ -374,173 +437,224 @@ 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) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("LocalPreferences", "Failed to decode saved File Server", e)
|
||||
e.printStackTrace()
|
||||
Nip96MediaServers.DEFAULT[0]
|
||||
}
|
||||
|
||||
val zapPaymentRequestServer =
|
||||
try {
|
||||
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
|
||||
Event.mapper.readValue<Nip47URI?>(it)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Error Decoding Zap Payment Request Server ${getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)}",
|
||||
e,
|
||||
)
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
|
||||
val latestContactList =
|
||||
try {
|
||||
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
|
||||
println("Decoding Contact List: " + it)
|
||||
if (it != null) {
|
||||
Event.fromJson(it) as ContactListEvent?
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}",
|
||||
e,
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
val languagePreferences =
|
||||
try {
|
||||
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
|
||||
Event.mapper.readValue<Map<String, String>?>(it)
|
||||
}
|
||||
?: mapOf()
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Error Decoding Language Preferences ${getString(PrefKeys.LANGUAGE_PREFS, null)}",
|
||||
e,
|
||||
)
|
||||
e.printStackTrace()
|
||||
mapOf()
|
||||
}
|
||||
|
||||
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
val hideNIP24WarningDialog = getBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, false)
|
||||
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
|
||||
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
|
||||
val proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
|
||||
val showSensitiveContent =
|
||||
if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
|
||||
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true)
|
||||
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
|
||||
|
||||
val lastReadPerRoute =
|
||||
try {
|
||||
getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let {
|
||||
Event.mapper.readValue<Map<String, Long>?>(it)
|
||||
}
|
||||
?: mapOf()
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Error Decoding Last Read per route ${getString(PrefKeys.LAST_READ_PER_ROUTE, null)}",
|
||||
e,
|
||||
)
|
||||
e.printStackTrace()
|
||||
mapOf()
|
||||
}
|
||||
|
||||
val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray())
|
||||
val signer =
|
||||
if (loginWithExternalSigner) {
|
||||
val packageName =
|
||||
getString(PrefKeys.SIGNER_PACKAGE_NAME, null) ?: "com.greenart7c3.nostrsigner"
|
||||
NostrSignerExternal(
|
||||
pubKey,
|
||||
ExternalSignerLauncher(pubKey.hexToByteArray().toNpub(), packageName),
|
||||
)
|
||||
} else {
|
||||
NostrSignerInternal(keyPair)
|
||||
}
|
||||
|
||||
val account =
|
||||
Account(
|
||||
keyPair = keyPair,
|
||||
signer = signer,
|
||||
localRelays = localRelays,
|
||||
dontTranslateFrom = dontTranslateFrom,
|
||||
languagePreferences = languagePreferences,
|
||||
translateTo = translateTo,
|
||||
zapAmountChoices = zapAmountChoices,
|
||||
reactionChoices = reactionChoices,
|
||||
defaultZapType = defaultZapType,
|
||||
defaultFileServer = defaultFileServer,
|
||||
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
|
||||
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
|
||||
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList),
|
||||
defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList),
|
||||
zapPaymentRequest = zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog = hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
hideNIP24WarningDialog = hideNIP24WarningDialog,
|
||||
backupContactList = latestContactList,
|
||||
proxy = proxy,
|
||||
proxyPort = proxyPort,
|
||||
showSensitiveContent = showSensitiveContent,
|
||||
warnAboutPostsWithReports = warnAboutReports,
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
lastReadPerRoute = lastReadPerRoute,
|
||||
)
|
||||
|
||||
// Loads from DB
|
||||
account.userProfile()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
// Loads Live Objects
|
||||
account.userProfile().live()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w("LocalPreferences", "Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}", e)
|
||||
null
|
||||
|
||||
return@with account
|
||||
}
|
||||
|
||||
val languagePreferences = try {
|
||||
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
|
||||
Event.mapper.readValue<Map<String, String>?>(it)
|
||||
} ?: mapOf()
|
||||
} catch (e: Throwable) {
|
||||
Log.w("LocalPreferences", "Error Decoding Language Preferences ${getString(PrefKeys.LANGUAGE_PREFS, null)}", e)
|
||||
e.printStackTrace()
|
||||
mapOf()
|
||||
}
|
||||
|
||||
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
val hideNIP24WarningDialog = getBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, false)
|
||||
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
|
||||
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
|
||||
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
|
||||
val showSensitiveContent = if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
|
||||
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true)
|
||||
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
|
||||
|
||||
val lastReadPerRoute = try {
|
||||
getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let {
|
||||
Event.mapper.readValue<Map<String, Long>?>(it)
|
||||
} ?: mapOf()
|
||||
} catch (e: Throwable) {
|
||||
Log.w("LocalPreferences", "Error Decoding Last Read per route ${getString(PrefKeys.LAST_READ_PER_ROUTE, null)}", e)
|
||||
e.printStackTrace()
|
||||
mapOf()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -10,7 +30,7 @@ import coil.decode.SvgDecoder
|
||||
import coil.size.Precision
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
@@ -31,18 +51,19 @@ import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.quartz.encoders.bechToBytes
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
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())
|
||||
@@ -63,18 +84,20 @@ class ServiceManager {
|
||||
val myAccount = account
|
||||
|
||||
// Resets Proxy Use
|
||||
HttpClient.start(account?.proxy)
|
||||
OptOutFromFilters.start(account?.warnAboutPostsWithReports ?: true, account?.filterSpamFromStrangers ?: true)
|
||||
HttpClientManager.setDefaultProxy(account?.proxy)
|
||||
LocalCache.antiSpam.active = account?.filterSpamFromStrangers ?: true
|
||||
Coil.setImageLoader {
|
||||
Amethyst.instance.imageLoaderBuilder().components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
} // .logger(DebugLogger())
|
||||
.okHttpClient { HttpClient.getHttpClient() }
|
||||
Amethyst.instance
|
||||
.imageLoaderBuilder()
|
||||
.components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
} // .logger(DebugLogger())
|
||||
.okHttpClient { HttpClientManager.getHttpClient() }
|
||||
.precision(Precision.INEXACT)
|
||||
.respectCacheHeaders(false)
|
||||
.build()
|
||||
@@ -87,24 +110,27 @@ 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) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
NostrHomeDataSource.account = myAccount
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
NostrVideoDataSource.account = myAccount
|
||||
@@ -163,9 +189,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 +207,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
@@ -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()) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,26 +1,174 @@
|
||||
/**
|
||||
* 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))
|
||||
"₿itcoin",
|
||||
"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 +178,5 @@ class HashtagIcon(
|
||||
val icon: Int,
|
||||
val description: String,
|
||||
val color: Color,
|
||||
val modifier: Modifier
|
||||
val modifier: Modifier,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
@@ -41,7 +62,8 @@ 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 kotlinx.collections.immutable.toImmutableList
|
||||
import com.vitorpamplona.quartz.utils.containsAny
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import java.math.BigDecimal
|
||||
@@ -52,9 +74,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,25 +106,31 @@ 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?>()
|
||||
private set
|
||||
|
||||
var relays = listOf<String>()
|
||||
var relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
|
||||
private set
|
||||
|
||||
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
|
||||
|
||||
fun id() = Hex.decode(idHex)
|
||||
|
||||
open fun idNote() = id().toNote()
|
||||
|
||||
open fun toNEvent(): String {
|
||||
@@ -110,13 +142,13 @@ open class Note(val idHex: String) {
|
||||
host.id,
|
||||
host.pubKey,
|
||||
host.kind(),
|
||||
relays.firstOrNull()
|
||||
relays.firstOrNull()?.url,
|
||||
)
|
||||
} else {
|
||||
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
|
||||
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
|
||||
}
|
||||
} else {
|
||||
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
|
||||
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,15 +159,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 +179,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 +195,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 +210,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 +273,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 +306,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>>()
|
||||
@@ -271,7 +323,7 @@ open class Note(val idHex: String) {
|
||||
zaps = mapOf<Note, Note?>()
|
||||
zapPayments = mapOf<Note, Note?>()
|
||||
zapsAmount = BigDecimal.ZERO
|
||||
relays = listOf<String>()
|
||||
relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
|
||||
lastReactionsDownloadTime = emptyMap()
|
||||
|
||||
liveSet?.innerReplies?.invalidateData()
|
||||
@@ -287,7 +339,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 +358,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 +379,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 +396,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 +408,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 +424,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 +436,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 +453,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,25 +466,27 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addRelaySync(url: String) {
|
||||
if (url !in relays) {
|
||||
relays = relays + url
|
||||
fun addRelaySync(briefInfo: RelayBriefInfoCache.RelayBriefInfo) {
|
||||
if (briefInfo !in relays) {
|
||||
relays = relays + briefInfo
|
||||
}
|
||||
}
|
||||
|
||||
fun addRelay(relay: Relay) {
|
||||
if (relay.url !in relays) {
|
||||
addRelaySync(relay.url)
|
||||
if (relay.brief !in relays) {
|
||||
addRelaySync(relay.brief)
|
||||
liveSet?.innerRelays?.invalidateData()
|
||||
}
|
||||
}
|
||||
@@ -444,7 +494,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 +505,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 +526,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 +538,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 +594,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 +629,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 +641,13 @@ 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) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
var newAmount = output
|
||||
|
||||
@@ -586,31 +661,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 +692,14 @@ 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) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
// do nothing if it can't convert to bigdecimal
|
||||
}
|
||||
|
||||
pledgeValue != null && it.author == user
|
||||
}
|
||||
@@ -636,6 +712,7 @@ open class Note(val idHex: String) {
|
||||
try {
|
||||
BigDecimal(it.event?.content())
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
// do nothing if it can't convert to bigdecimal
|
||||
}
|
||||
@@ -647,10 +724,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 +735,7 @@ open class Note(val idHex: String) {
|
||||
event is GenericRepostEvent ||
|
||||
replyTo == null ||
|
||||
replyTo?.size == 0
|
||||
) &&
|
||||
) &&
|
||||
event !is ChannelMessageEvent &&
|
||||
event !is LiveActivitiesChatMessageEvent
|
||||
}
|
||||
@@ -668,11 +744,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 +763,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 +816,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,45 +935,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 boostList = innerBoosts.map {
|
||||
it.note.boosts.toImmutableList()
|
||||
}.distinctUntilChanged()
|
||||
val relayInfo = innerRelays.map { it.note.relays }
|
||||
|
||||
val relayInfo = innerRelays.map {
|
||||
it.note.relays.map {
|
||||
RelayBriefInfo(it)
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
val content = innerMetadata.map {
|
||||
it.note.event?.content() ?: ""
|
||||
}
|
||||
val content = innerMetadata.map { it.note.event?.content() ?: "" }
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return metadata.hasObservers() ||
|
||||
@@ -897,8 +979,7 @@ class NoteLiveSet(u: Note) {
|
||||
hasReactions.hasObservers() ||
|
||||
replyCount.hasObservers() ||
|
||||
reactionCount.hasObservers() ||
|
||||
boostCount.hasObservers() ||
|
||||
boostList.hasObservers()
|
||||
boostCount.hasObservers()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
@@ -925,7 +1006,7 @@ class NoteBundledRefresherFlow(val note: Note) {
|
||||
fun invalidateData() {
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate() {
|
||||
bundler.invalidate {
|
||||
checkNotInMainThread()
|
||||
|
||||
stateFlow.emit(NoteState(note))
|
||||
@@ -945,16 +1026,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) }
|
||||
@@ -983,12 +1062,25 @@ class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveDat
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
class NoteState(val note: Note)
|
||||
@Immutable class NoteState(val note: Note)
|
||||
|
||||
@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()
|
||||
)
|
||||
object RelayBriefInfoCache {
|
||||
val cache = LruCache<String, RelayBriefInfo?>(50)
|
||||
|
||||
@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(),
|
||||
)
|
||||
|
||||
fun get(url: String): RelayBriefInfo {
|
||||
val info = cache[url]
|
||||
if (info != null) return info
|
||||
|
||||
val newInfo = RelayBriefInfo(url)
|
||||
cache.put(url, newInfo)
|
||||
return newInfo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
@@ -13,7 +33,7 @@ data class RelaySetupInfo(
|
||||
val uploadCountInBytes: Int = 0,
|
||||
val spamCount: Int = 0,
|
||||
val feedTypes: Set<FeedType>,
|
||||
val paidRelay: Boolean = false
|
||||
val paidRelay: Boolean = false,
|
||||
) {
|
||||
val briefInfo: RelayBriefInfo = RelayBriefInfo(url)
|
||||
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,6 +34,9 @@ import com.vitorpamplona.amethyst.ui.components.imageExtensions
|
||||
import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionComparison
|
||||
import com.vitorpamplona.amethyst.ui.components.tagIndex
|
||||
import com.vitorpamplona.amethyst.ui.components.videoExtensions
|
||||
import com.vitorpamplona.quartz.encoders.Nip54
|
||||
import com.vitorpamplona.quartz.encoders.Nip92
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
@@ -22,6 +45,8 @@ import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.util.regex.Pattern
|
||||
|
||||
@Immutable
|
||||
@@ -30,7 +55,7 @@ data class RichTextViewerState(
|
||||
val imagesForPager: ImmutableMap<String, ZoomableUrlContent>,
|
||||
val imageList: ImmutableList<ZoomableUrlContent>,
|
||||
val customEmoji: ImmutableMap<String, String>,
|
||||
val paragraphs: ImmutableList<ParagraphState>
|
||||
val paragraphs: ImmutableList<ParagraphState>,
|
||||
)
|
||||
|
||||
data class ParagraphState(val words: ImmutableList<Segment>, val isRTL: Boolean)
|
||||
@@ -38,7 +63,10 @@ data class ParagraphState(val words: ImmutableList<Segment>, val isRTL: Boolean)
|
||||
object CachedRichTextParser {
|
||||
val richTextCache = LruCache<String, RichTextViewerState>(200)
|
||||
|
||||
fun parseText(content: String, tags: ImmutableListOfLists<String>): RichTextViewerState {
|
||||
fun parseText(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
): RichTextViewerState {
|
||||
return if (richTextCache[content] != null) {
|
||||
richTextCache[content]
|
||||
} else {
|
||||
@@ -50,37 +78,53 @@ 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? {
|
||||
fun parseMediaUrl(
|
||||
fullUrl: String,
|
||||
eventTags: ImmutableListOfLists<String>,
|
||||
): ZoomableUrlContent? {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
|
||||
return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
val frags = Nip44UrlParser().parse(fullUrl)
|
||||
val frags = Nip54().parse(fullUrl)
|
||||
val tags = Nip92().parse(fullUrl, eventTags.lists)
|
||||
|
||||
ZoomableUrlImage(
|
||||
url = fullUrl,
|
||||
description = frags["alt"],
|
||||
hash = frags["x"],
|
||||
blurhash = frags["blurhash"],
|
||||
dim = frags["dim"]
|
||||
description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
|
||||
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
|
||||
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
|
||||
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
|
||||
contentWarning = frags["content-warning"] ?: tags["content-warning"],
|
||||
)
|
||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
val frags = Nip44UrlParser().parse(fullUrl)
|
||||
val frags = Nip54().parse(fullUrl)
|
||||
val tags = Nip92().parse(fullUrl, eventTags.lists)
|
||||
ZoomableUrlVideo(
|
||||
url = fullUrl,
|
||||
description = frags["alt"],
|
||||
hash = frags["x"],
|
||||
blurhash = frags["blurhash"],
|
||||
dim = frags["dim"]
|
||||
description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
|
||||
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
|
||||
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
|
||||
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
|
||||
contentWarning = frags["content-warning"] ?: tags["content-warning"],
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -89,30 +133,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, tags) }.associateBy { it.url }
|
||||
val imageList = imagesForPager.values.toList()
|
||||
|
||||
val emojiMap =
|
||||
@@ -125,38 +169,45 @@ class RichTextParser() {
|
||||
imagesForPager.toImmutableMap(),
|
||||
imageList.toImmutableList(),
|
||||
emojiMap.toImmutableMap(),
|
||||
segments
|
||||
segments,
|
||||
)
|
||||
}
|
||||
|
||||
private fun findTextSegments(content: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): ImmutableList<ParagraphState> {
|
||||
var paragraphSegments = persistentListOf<ParagraphState>()
|
||||
private fun findTextSegments(
|
||||
content: String,
|
||||
images: Set<String>,
|
||||
urls: Set<String>,
|
||||
emojis: Map<String, String>,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
): ImmutableList<ParagraphState> {
|
||||
val lines = content.split('\n')
|
||||
val paragraphSegments = ArrayList<ParagraphState>(lines.size)
|
||||
|
||||
content.split('\n').forEach { paragraph ->
|
||||
var segments = persistentListOf<Segment>()
|
||||
lines.forEach { paragraph ->
|
||||
var isDirty = false
|
||||
|
||||
val isRTL = isArabic(paragraph)
|
||||
|
||||
val wordList = paragraph.trimEnd().split(' ')
|
||||
val segments = ArrayList<Segment>(wordList.size)
|
||||
wordList.forEach { word ->
|
||||
val wordSegment = wordIdentifier(word, images, urls, emojis, tags)
|
||||
if (wordSegment !is RegularTextSegment) {
|
||||
isDirty = true
|
||||
}
|
||||
segments = segments.add(wordSegment)
|
||||
segments.add(wordSegment)
|
||||
}
|
||||
|
||||
val newSegments = if (isDirty) {
|
||||
ParagraphState(segments, isRTL)
|
||||
} else {
|
||||
ParagraphState(persistentListOf<Segment>(RegularTextSegment(paragraph)), isRTL)
|
||||
}
|
||||
val newSegments =
|
||||
if (isDirty) {
|
||||
ParagraphState(segments.toPersistentList(), isRTL)
|
||||
} else {
|
||||
ParagraphState(persistentListOf<Segment>(RegularTextSegment(paragraph)), isRTL)
|
||||
}
|
||||
|
||||
paragraphSegments = paragraphSegments.add(newSegments)
|
||||
paragraphSegments.add(newSegments)
|
||||
}
|
||||
|
||||
return paragraphSegments
|
||||
return paragraphSegments.toImmutableList()
|
||||
}
|
||||
|
||||
fun isNumber(word: String): Boolean {
|
||||
@@ -171,7 +222,13 @@ class RichTextParser() {
|
||||
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
|
||||
}
|
||||
|
||||
private fun wordIdentifier(word: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): Segment {
|
||||
private fun wordIdentifier(
|
||||
word: String,
|
||||
images: Set<String>,
|
||||
urls: Set<String>,
|
||||
emojis: Map<String, String>,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
): Segment {
|
||||
val emailMatcher = Patterns.EMAIL_ADDRESS.matcher(word)
|
||||
val phoneMatcher = Patterns.PHONE.matcher(word)
|
||||
val schemelessMatcher = noProtocolUrlValidator.matcher(word)
|
||||
@@ -201,7 +258,9 @@ class RichTextParser() {
|
||||
} else if (word.contains(".") && schemelessMatcher.find()) {
|
||||
val url = schemelessMatcher.group(1) // url
|
||||
val additionalChars = schemelessMatcher.group(4) // additional chars
|
||||
val pattern = """^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?""".toRegex(RegexOption.IGNORE_CASE)
|
||||
val pattern =
|
||||
"""^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?"""
|
||||
.toRegex(RegexOption.IGNORE_CASE)
|
||||
if (pattern.find(word) != null) {
|
||||
SchemelessUrlSegment(word, url, additionalChars)
|
||||
} else {
|
||||
@@ -212,7 +271,10 @@ class RichTextParser() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseHash(word: String, tags: ImmutableListOfLists<String>): Segment {
|
||||
private fun parseHash(
|
||||
word: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
): Segment {
|
||||
// First #[n]
|
||||
|
||||
val matcher = tagIndex.matcher(word)
|
||||
@@ -234,6 +296,7 @@ class RichTextParser() {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("Tag Parser", "Couldn't link tag $word", e)
|
||||
}
|
||||
|
||||
@@ -248,6 +311,7 @@ class RichTextParser() {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
|
||||
}
|
||||
|
||||
@@ -261,53 +325,46 @@ class RichTextParser() {
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
open class Segment(val segmentText: String)
|
||||
@Immutable open class Segment(val segmentText: String)
|
||||
|
||||
@Immutable class ImageSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable class LinkSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable class EmojiSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable class InvoiceSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable class WithdrawSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable class CashuSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable class EmailSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable class PhoneSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable class BechSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class ImageSegment(segment: String) : Segment(segment)
|
||||
open class HashIndexSegment(segment: String, val hex: String, val extras: String?) :
|
||||
Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class LinkSegment(segment: String) : Segment(segment)
|
||||
class HashIndexUserSegment(segment: String, hex: String, extras: String?) :
|
||||
HashIndexSegment(segment, hex, extras)
|
||||
|
||||
@Immutable
|
||||
class EmojiSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class InvoiceSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class WithdrawSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class CashuSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class EmailSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class PhoneSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class BechSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
open class HashIndexSegment(segment: String, val hex: String, val extras: String?) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class HashIndexUserSegment(segment: String, hex: String, extras: String?) : HashIndexSegment(segment, hex, extras)
|
||||
|
||||
@Immutable
|
||||
class HashIndexEventSegment(segment: String, hex: String, extras: String?) : HashIndexSegment(segment, hex, extras)
|
||||
class HashIndexEventSegment(segment: String, hex: String, extras: String?) :
|
||||
HashIndexSegment(segment, hex, extras)
|
||||
|
||||
@Immutable
|
||||
class HashTagSegment(segment: String, val hashtag: String, val extras: String?) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class SchemelessUrlSegment(segment: String, val url: String, val extras: String?) : Segment(segment)
|
||||
class SchemelessUrlSegment(segment: String, val url: String, val extras: String?) :
|
||||
Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class RegularTextSegment(segment: String) : Segment(segment)
|
||||
@Immutable class RegularTextSegment(segment: String) : Segment(segment)
|
||||
|
||||
fun startsWithNIP19Scheme(word: String): Boolean {
|
||||
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@")
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
@@ -12,13 +32,14 @@ import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.Base64
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Immutable
|
||||
data class CashuToken(
|
||||
val token: String,
|
||||
val mint: String,
|
||||
val totalAmount: Long,
|
||||
val proofs: JsonNode
|
||||
val proofs: JsonNode,
|
||||
)
|
||||
|
||||
class CashuProcessor {
|
||||
@@ -39,45 +60,54 @@ class CashuProcessor {
|
||||
|
||||
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs))
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun melt(token: CashuToken, lud16: String, onSuccess: (String, String) -> Unit, onError: (String, String) -> Unit, context: Context) {
|
||||
suspend fun melt(
|
||||
token: CashuToken,
|
||||
lud16: String,
|
||||
onSuccess: (String, String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
runCatching {
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lnaddress = lud16,
|
||||
milliSats = token.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,12 +116,12 @@ class CashuProcessor {
|
||||
invoice: String,
|
||||
onSuccess: (Int) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context
|
||||
context: Context,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
try {
|
||||
val client = HttpClient.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
|
||||
|
||||
val factory = Event.mapper.nodeFactory
|
||||
@@ -101,10 +131,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 +141,7 @@ class CashuProcessor {
|
||||
|
||||
if (feeCost != null) {
|
||||
onSuccess(
|
||||
feeCost
|
||||
feeCost,
|
||||
)
|
||||
} else {
|
||||
val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null }
|
||||
@@ -124,18 +151,29 @@ 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))
|
||||
if (e is CancellationException) throw e
|
||||
onError(
|
||||
context.getString(R.string.cashu_successful_redemption),
|
||||
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun meltInvoice(token: CashuToken, invoice: String, 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 client = HttpClientManager.getHttpClient()
|
||||
val url = token.mint + "/melt" // Melt cashu tokens at Mint
|
||||
|
||||
val factory = Event.mapper.nodeFactory
|
||||
@@ -146,10 +184,7 @@ class CashuProcessor {
|
||||
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = jsonObject.toString().toRequestBody(mediaType)
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
val request = Request.Builder().url(url).post(requestBody).build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
val body = it.body.string()
|
||||
@@ -160,7 +195,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 +209,16 @@ class CashuProcessor {
|
||||
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg)
|
||||
} else {
|
||||
context.getString(R.string.cashu_failed_redemption_explainer_error_msg)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onError(context.getString(R.string.cashu_successful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message))
|
||||
if (e is CancellationException) throw e
|
||||
onError(
|
||||
context.getString(R.string.cashu_successful_redemption),
|
||||
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -12,6 +32,7 @@ import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import io.trbl.blurhash.BlurHash
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.io.IOException
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -20,7 +41,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 +49,7 @@ class FileHeader(
|
||||
mimeType: String?,
|
||||
dimPrecomputed: String?,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: () -> Unit
|
||||
onError: (String?) -> Unit,
|
||||
) {
|
||||
try {
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
|
||||
@@ -36,11 +57,12 @@ class FileHeader(
|
||||
if (imageData != null) {
|
||||
prepare(imageData, mimeType, dimPrecomputed, onReady, onError)
|
||||
} else {
|
||||
onError()
|
||||
onError(null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
|
||||
onError()
|
||||
onError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,81 +71,114 @@ 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) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
|
||||
onError()
|
||||
onError(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,7 +198,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 +242,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)
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.time.Duration
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
object HttpClient {
|
||||
val DEFAULT_TIMEOUT_ON_WIFI = Duration.ofSeconds(10L)
|
||||
val DEFAULT_TIMEOUT_ON_MOBILE = Duration.ofSeconds(30L)
|
||||
|
||||
var proxyChangeListeners = ArrayList<() -> Unit>()
|
||||
var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI
|
||||
|
||||
var defaultHttpClient: OkHttpClient? = null
|
||||
|
||||
// fires off every time value of the property changes
|
||||
private var internalProxy: Proxy? by Delegates.observable(null) { _, oldValue, newValue ->
|
||||
if (oldValue != newValue) {
|
||||
proxyChangeListeners.forEach {
|
||||
it()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun start(proxy: Proxy?) {
|
||||
if (internalProxy != proxy) {
|
||||
this.internalProxy = proxy
|
||||
this.defaultHttpClient = getHttpClient()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeTimeouts(timeout: Duration) {
|
||||
Log.d("HttpClient", "Changing timeout to: $timeout")
|
||||
if (this.defaultTimeout.seconds != timeout.seconds) {
|
||||
this.defaultTimeout = timeout
|
||||
this.defaultHttpClient = getHttpClient()
|
||||
}
|
||||
}
|
||||
|
||||
fun getHttpClient(timeout: Duration): OkHttpClient {
|
||||
val seconds = if (internalProxy != null) timeout.seconds * 2 else timeout.seconds
|
||||
val duration = Duration.ofSeconds(seconds)
|
||||
return OkHttpClient.Builder()
|
||||
.proxy(internalProxy)
|
||||
.readTimeout(duration)
|
||||
.connectTimeout(duration)
|
||||
.writeTimeout(duration)
|
||||
.addInterceptor(DefaultContentTypeInterceptor())
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
class DefaultContentTypeInterceptor : Interceptor {
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest: Request = chain.request()
|
||||
val requestWithUserAgent: Request = originalRequest
|
||||
.newBuilder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.build()
|
||||
return chain.proceed(requestWithUserAgent)
|
||||
}
|
||||
}
|
||||
|
||||
fun getHttpClientForRelays(): OkHttpClient {
|
||||
if (this.defaultHttpClient == null) {
|
||||
this.defaultHttpClient = getHttpClient(defaultTimeout)
|
||||
}
|
||||
return defaultHttpClient!!
|
||||
}
|
||||
|
||||
fun getHttpClient(): OkHttpClient {
|
||||
if (this.defaultHttpClient == null) {
|
||||
this.defaultHttpClient = getHttpClient(defaultTimeout)
|
||||
}
|
||||
return defaultHttpClient!!
|
||||
}
|
||||
|
||||
fun getProxy(): Proxy? {
|
||||
return internalProxy
|
||||
}
|
||||
|
||||
fun initProxy(useProxy: Boolean, hostname: String, port: Int): Proxy? {
|
||||
return if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) else null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.time.Duration
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
object HttpClientManager {
|
||||
val DEFAULT_TIMEOUT_ON_WIFI: Duration = Duration.ofSeconds(10L)
|
||||
val DEFAULT_TIMEOUT_ON_MOBILE: Duration = Duration.ofSeconds(30L)
|
||||
|
||||
var proxyChangeListeners = ArrayList<() -> Unit>()
|
||||
private var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI
|
||||
private var defaultHttpClient: OkHttpClient? = null
|
||||
|
||||
// fires off every time value of the property changes
|
||||
private var internalProxy: Proxy? by
|
||||
Delegates.observable(null) { _, oldValue, newValue ->
|
||||
if (oldValue != newValue) {
|
||||
proxyChangeListeners.forEach { it() }
|
||||
}
|
||||
}
|
||||
|
||||
fun setDefaultProxy(proxy: Proxy?) {
|
||||
if (internalProxy != proxy) {
|
||||
Log.d("HttpClient", "Changing proxy to: ${proxy != null}")
|
||||
this.internalProxy = proxy
|
||||
|
||||
// recreates singleton
|
||||
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
fun setDefaultTimeout(timeout: Duration) {
|
||||
Log.d("HttpClient", "Changing timeout to: $timeout")
|
||||
if (this.defaultTimeout.seconds != timeout.seconds) {
|
||||
this.defaultTimeout = timeout
|
||||
|
||||
// recreates singleton
|
||||
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildHttpClient(
|
||||
proxy: Proxy?,
|
||||
timeout: Duration,
|
||||
): OkHttpClient {
|
||||
val seconds = if (proxy != null) timeout.seconds * 2 else timeout.seconds
|
||||
val duration = Duration.ofSeconds(seconds)
|
||||
return OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
.readTimeout(duration)
|
||||
.connectTimeout(duration)
|
||||
.writeTimeout(duration)
|
||||
.addInterceptor(DefaultContentTypeInterceptor())
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
class DefaultContentTypeInterceptor : Interceptor {
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest: Request = chain.request()
|
||||
val requestWithUserAgent: Request =
|
||||
originalRequest
|
||||
.newBuilder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.build()
|
||||
return chain.proceed(requestWithUserAgent)
|
||||
}
|
||||
}
|
||||
|
||||
fun getHttpClient(): OkHttpClient {
|
||||
if (this.defaultHttpClient == null) {
|
||||
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
|
||||
}
|
||||
return defaultHttpClient!!
|
||||
}
|
||||
|
||||
fun initProxy(
|
||||
useProxy: Boolean,
|
||||
hostname: String,
|
||||
port: Int,
|
||||
): Proxy? {
|
||||
return if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) else null
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,23 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
@@ -9,6 +29,7 @@ import android.location.LocationManager
|
||||
import android.os.HandlerThread
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class LocationUtil(context: Context) {
|
||||
@@ -17,7 +38,8 @@ class LocationUtil(context: Context) {
|
||||
const val MIN_DISTANCE: Float = 0.0f
|
||||
}
|
||||
|
||||
private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
private val locationManager =
|
||||
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
private var locationListener: LocationListener? = null
|
||||
|
||||
val locationStateFlow = MutableStateFlow<Location>(Location(LocationManager.NETWORK_PROVIDER))
|
||||
@@ -31,47 +53,60 @@ class LocationUtil(context: Context) {
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun start(minTimeMs: Long = MIN_TIME, minDistanceM: Float = MIN_DISTANCE) {
|
||||
fun start(
|
||||
minTimeMs: Long = MIN_TIME,
|
||||
minDistanceM: Float = MIN_DISTANCE,
|
||||
) {
|
||||
locationListener().let {
|
||||
locationListener = it
|
||||
locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, minTimeMs, minDistanceM, it, locHandlerThread.looper)
|
||||
locationManager.requestLocationUpdates(
|
||||
LocationManager.NETWORK_PROVIDER,
|
||||
minTimeMs,
|
||||
minDistanceM,
|
||||
it,
|
||||
locHandlerThread.looper,
|
||||
)
|
||||
}
|
||||
providerState.value = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
|
||||
isStart.value = true
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
locationListener?.let {
|
||||
locationManager.removeUpdates(it)
|
||||
}
|
||||
locationListener?.let { locationManager.removeUpdates(it) }
|
||||
isStart.value = false
|
||||
}
|
||||
|
||||
private fun locationListener() = object : LocationListener {
|
||||
override fun onLocationChanged(location: Location) {
|
||||
locationStateFlow.value = location
|
||||
}
|
||||
private fun locationListener() =
|
||||
object : LocationListener {
|
||||
override fun onLocationChanged(location: Location) {
|
||||
locationStateFlow.value = location
|
||||
}
|
||||
|
||||
override fun onProviderEnabled(provider: String) {
|
||||
providerState.value = true
|
||||
}
|
||||
override fun onProviderEnabled(provider: String) {
|
||||
providerState.value = true
|
||||
}
|
||||
|
||||
override fun onProviderDisabled(provider: String) {
|
||||
providerState.value = false
|
||||
override fun onProviderDisabled(provider: String) {
|
||||
providerState.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ReverseGeoLocationUtil {
|
||||
suspend fun execute(
|
||||
location: Location,
|
||||
context: Context
|
||||
context: Context,
|
||||
): String? {
|
||||
return try {
|
||||
Geocoder(context).getFromLocation(location.latitude, location.longitude, 1)?.firstOrNull()?.let { address ->
|
||||
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode).joinToString(", ")
|
||||
}
|
||||
Geocoder(context)
|
||||
.getFromLocation(location.latitude, location.longitude, 1)
|
||||
?.firstOrNull()
|
||||
?.let { address ->
|
||||
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode)
|
||||
.joinToString(", ")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
e.printStackTrace()
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Call
|
||||
@@ -23,7 +44,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 +59,55 @@ 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()
|
||||
HttpClientManager.getHttpClient()
|
||||
.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
override fun onResponse(
|
||||
call: Call,
|
||||
response: Response,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError("Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct")
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError(
|
||||
"Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not resolve $url. Check if the server is up and if the address $nip05 is correct")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
} catch (e: java.lang.Exception) {
|
||||
override fun onFailure(
|
||||
call: Call,
|
||||
e: java.io.IOException,
|
||||
) {
|
||||
onError(
|
||||
"Could not resolve $url. Check if the server is up and if the address $nip05 is correct",
|
||||
)
|
||||
e.printStackTrace()
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError("Could not resolve '$url': ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
suspend fun verifyNip05(
|
||||
nip05: String,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
// check fails on tests
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -76,19 +121,22 @@ 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 (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
|
||||
null
|
||||
}
|
||||
|
||||
val parts = nip05.split("@")
|
||||
val user = if (parts.size == 2) {
|
||||
parts[0].lowercase()
|
||||
} else {
|
||||
"_"
|
||||
}
|
||||
val user =
|
||||
if (parts.size == 2) {
|
||||
parts[0].lowercase()
|
||||
} else {
|
||||
"_"
|
||||
}
|
||||
|
||||
val hexKey = nip05url?.get("names")?.get(user)?.asText()
|
||||
|
||||
@@ -98,7 +146,7 @@ class Nip05NostrAddressVerifier() {
|
||||
onSuccess(hexKey)
|
||||
}
|
||||
},
|
||||
onError = onError
|
||||
onError = onError,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +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.service
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Request
|
||||
@@ -16,7 +37,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 +45,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 +64,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 +79,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()
|
||||
HttpClientManager.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,19 +103,28 @@ 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)
|
||||
if (e is CancellationException) throw e
|
||||
Log.e(
|
||||
"RelayInfoFail",
|
||||
"Resulting Message from Relay $dirtyUrl in not parseable: $body",
|
||||
e,
|
||||
)
|
||||
onError(dirtyUrl, ErrorCode.FAIL_TO_PARSE_RESULT, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
override fun onFailure(
|
||||
call: Call,
|
||||
e: IOException,
|
||||
) {
|
||||
Log.e("RelayInfoFail", "$dirtyUrl unavailable", e)
|
||||
onError(dirtyUrl, ErrorCode.FAIL_TO_REACH_SERVER, e.message)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e)
|
||||
onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
|
||||
class Nip44UrlParser {
|
||||
fun parse(url: String): Map<String, String> {
|
||||
return try {
|
||||
fragments(URI(url))
|
||||
} catch (e: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
private fun fragments(uri: URI): Map<String, String> {
|
||||
if (uri.rawFragment == null) return emptyMap()
|
||||
return uri.rawFragment.split('&').associate { keyValuePair ->
|
||||
val parts = keyValuePair.split('=')
|
||||
val name = parts.firstOrNull() ?: ""
|
||||
val value = parts.getOrNull(1)?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
|
||||
Pair(name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +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.ui.note
|
||||
|
||||
import android.net.Uri
|
||||
import com.vitorpamplona.amethyst.model.Nip47URI
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKey
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
// Rename to the corect nip number when ready.
|
||||
object Nip47WalletConnectParser {
|
||||
@@ -18,11 +39,13 @@ 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) {
|
||||
if (e is CancellationException) throw e
|
||||
throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey")
|
||||
}
|
||||
|
||||
val relay = url.getQueryParameter("relay")
|
||||
val secret = url.getQueryParameter("secret")
|
||||
|
||||
@@ -1,9 +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
|
||||
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.Request
|
||||
|
||||
object Nip96MediaServers {
|
||||
@@ -14,7 +35,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 +60,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 +69,47 @@ 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
|
||||
HttpClientManager.getHttpClient().newCall(request).execute().use { response ->
|
||||
checkNotInMainThread()
|
||||
response.use {
|
||||
val body = it.body.string()
|
||||
try {
|
||||
if (it.isSuccessful) {
|
||||
return parse(body)
|
||||
} else {
|
||||
throw RuntimeException(
|
||||
"Resulting Message from $baseUrl is an error: ${response.code} ${response.message}",
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typealias PlanName = String
|
||||
|
||||
typealias MimeType = String
|
||||
|
||||
@@ -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 client = HttpClientManager.getHttpClient()
|
||||
val requestBody: RequestBody
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("expiration", "")
|
||||
.addFormDataPart("size", length.toString())
|
||||
.also { body ->
|
||||
alt?.let { body.addFormDataPart("alt", it) }
|
||||
sensitiveContent?.let { body.addFormDataPart("content-warning", it) }
|
||||
contentType?.let { body.addFormDataPart("content_type", it) }
|
||||
}
|
||||
.addFormDataPart(
|
||||
"file",
|
||||
"$fileName.$extension",
|
||||
|
||||
object : RequestBody() {
|
||||
override fun contentType() = contentType?.toMediaType()
|
||||
|
||||
override fun contentLength() = length
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
inputStream.source().use(sink::writeAll)
|
||||
}
|
||||
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 client = HttpClientManager.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,24 +225,23 @@ 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()
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
var currentResult = result
|
||||
|
||||
while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) {
|
||||
onProgress((currentResult.percentage ?: 100) / 100f)
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(result.processingUrl)
|
||||
.build()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,48 @@ 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) {
|
||||
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
|
||||
}
|
||||
} else {
|
||||
// hasn't changed, does nothing.
|
||||
if (active) {
|
||||
Client.sendFilterOnlyIfDisconnected(
|
||||
updatedSubscription.id,
|
||||
updatedSubscriptionNewFilters,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// was not active and becomes active, sends the filter.
|
||||
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
|
||||
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 +282,40 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
changingFilters.getAndSet(false)
|
||||
}
|
||||
|
||||
open fun consume(event: Event, relay: Relay) {
|
||||
open fun consume(
|
||||
event: Event,
|
||||
relay: Relay,
|
||||
) {
|
||||
LocalCache.verifyAndConsume(event, relay)
|
||||
}
|
||||
|
||||
open fun markAsSeenOnRelay(eventId: String, relay: Relay) {
|
||||
open fun markAsSeenOnRelay(
|
||||
eventId: String,
|
||||
relay: Relay,
|
||||
) {
|
||||
LocalCache.getNoteIfExists(eventId)?.addRelay(relay)
|
||||
}
|
||||
|
||||
open fun markAsEOSE(subscriptionId: String, relay: Relay) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -26,6 +46,7 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
|
||||
private var searchString: String? = null
|
||||
@@ -36,75 +57,91 @@ 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) {
|
||||
if (e is CancellationException) throw e
|
||||
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
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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?) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
@@ -5,9 +25,9 @@ import android.util.LruCache
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import okhttp3.Request
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Immutable
|
||||
data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
|
||||
@Immutable data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
|
||||
|
||||
object OnlineChecker {
|
||||
val checkOnlineCache = LruCache<String, OnlineCheckResult>(100)
|
||||
@@ -32,19 +52,22 @@ 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 =
|
||||
HttpClientManager.getHttpClient().newCall(request).execute().use {
|
||||
checkNotInMainThread()
|
||||
it.isSuccessful
|
||||
}
|
||||
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false))
|
||||
Log.e("LiveActivities", "Failed to check streaming url $url", e)
|
||||
false
|
||||
|
||||
@@ -1,17 +1,50 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
|
||||
object PackageUtils {
|
||||
private fun isPackageInstalled(context: Context, target: String): Boolean {
|
||||
return context.packageManager.getInstalledApplications(0).find { info -> info.packageName == target } != null
|
||||
private fun isPackageInstalled(
|
||||
context: Context,
|
||||
target: String,
|
||||
): Boolean {
|
||||
return context.packageManager.getInstalledApplications(0).find { info ->
|
||||
info.packageName == target
|
||||
} != null
|
||||
}
|
||||
|
||||
fun isOrbotInstalled(context: Context): Boolean {
|
||||
return isPackageInstalled(context, "org.torproject.android")
|
||||
}
|
||||
|
||||
fun isAmberInstalled(context: Context): Boolean {
|
||||
return isPackageInstalled(context, "com.greenart7c3.nostrsigner")
|
||||
fun isExternalSignerInstalled(context: Context): Boolean {
|
||||
val intent =
|
||||
Intent().apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
data = Uri.parse("nostrsigner:")
|
||||
}
|
||||
val infos = context.packageManager.queryIntentActivities(intent, 0)
|
||||
return infos.size > 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +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.lnurl
|
||||
|
||||
import android.content.Context
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.encoders.Lud06
|
||||
@@ -13,9 +33,10 @@ import okhttp3.Request
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.net.URLEncoder
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class LightningAddressResolver() {
|
||||
val client = HttpClient.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
|
||||
fun assembleUrl(lnaddress: String): String? {
|
||||
val parts = lnaddress.split("@")
|
||||
@@ -35,7 +56,7 @@ class LightningAddressResolver() {
|
||||
lnaddress: String,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context
|
||||
context: Context,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -46,17 +67,18 @@ class LightningAddressResolver() {
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup,
|
||||
lnaddress
|
||||
)
|
||||
lnaddress,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
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,23 +87,27 @@ class LightningAddressResolver() {
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct,
|
||||
R.string
|
||||
.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct,
|
||||
url,
|
||||
lnaddress,
|
||||
it.code.toString()
|
||||
)
|
||||
it.code.toString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
e.printStackTrace()
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct,
|
||||
R.string
|
||||
.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception,
|
||||
url,
|
||||
lnaddress
|
||||
)
|
||||
lnaddress,
|
||||
e.suppressedExceptions.getOrNull(0)?.message ?: e.cause?.message ?: e.message,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -93,7 +119,7 @@ class LightningAddressResolver() {
|
||||
nostrRequest: String? = null,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context
|
||||
context: Context,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -107,10 +133,11 @@ class LightningAddressResolver() {
|
||||
url += "&nostr=$encodedNostrRequest"
|
||||
}
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
val request: Request =
|
||||
Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
@@ -118,20 +145,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 +173,7 @@ class LightningAddressResolver() {
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
context: Context
|
||||
context: Context,
|
||||
) {
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
@@ -152,22 +182,28 @@ class LightningAddressResolver() {
|
||||
onSuccess = { lnAddressJson ->
|
||||
onProgress(0.4f)
|
||||
|
||||
val lnurlp = try {
|
||||
mapper.readTree(lnAddressJson)
|
||||
} catch (t: Throwable) {
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup)
|
||||
)
|
||||
null
|
||||
}
|
||||
val lnurlp =
|
||||
try {
|
||||
mapper.readTree(lnAddressJson)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup,
|
||||
),
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
val callback = lnurlp?.get("callback")?.asText()
|
||||
|
||||
if (callback == null) {
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration)
|
||||
context.getString(
|
||||
R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -182,59 +218,79 @@ class LightningAddressResolver() {
|
||||
onSuccess = {
|
||||
onProgress(0.6f)
|
||||
|
||||
val lnInvoice = try {
|
||||
mapper.readTree(it)
|
||||
} catch (t: Throwable) {
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(R.string.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup)
|
||||
)
|
||||
null
|
||||
}
|
||||
val lnInvoice =
|
||||
try {
|
||||
mapper.readTree(it)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string
|
||||
.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup,
|
||||
),
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
lnInvoice?.get("pr")?.asText()?.ifBlank { null }?.let { pr ->
|
||||
// Forces LN Invoice amount to be the requested amount.
|
||||
val expectedAmountInSats = BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong()
|
||||
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
|
||||
if (invoiceAmount.toLong() == expectedAmountInSats) {
|
||||
onProgress(0.7f)
|
||||
onSuccess(pr)
|
||||
} else {
|
||||
lnInvoice
|
||||
?.get("pr")
|
||||
?.asText()
|
||||
?.ifBlank { null }
|
||||
?.let { pr ->
|
||||
// Forces LN Invoice amount to be the requested amount.
|
||||
val expectedAmountInSats =
|
||||
BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong()
|
||||
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
|
||||
if (invoiceAmount.toLong() == expectedAmountInSats) {
|
||||
onProgress(0.7f)
|
||||
onSuccess(pr)
|
||||
} else {
|
||||
onProgress(0.0f)
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string.incorrect_invoice_amount_sats_from_it_should_have_been,
|
||||
invoiceAmount.toLong().toString(),
|
||||
lnaddress,
|
||||
expectedAmountInSats.toString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
?: lnInvoice
|
||||
?.get("reason")
|
||||
?.asText()
|
||||
?.ifBlank { null }
|
||||
?.let { reason ->
|
||||
onProgress(0.0f)
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string
|
||||
.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error,
|
||||
reason,
|
||||
),
|
||||
)
|
||||
}
|
||||
?: run {
|
||||
onProgress(0.0f)
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string.incorrect_invoice_amount_sats_from_it_should_have_been,
|
||||
invoiceAmount.toLong().toString(),
|
||||
lnaddress,
|
||||
expectedAmountInSats.toString()
|
||||
)
|
||||
R.string
|
||||
.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json,
|
||||
),
|
||||
)
|
||||
}
|
||||
} ?: lnInvoice?.get("reason")?.asText()?.ifBlank { null }?.let { reason ->
|
||||
onProgress(0.0f)
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error,
|
||||
reason
|
||||
)
|
||||
)
|
||||
} ?: run {
|
||||
onProgress(0.0f)
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(R.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json)
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = onError,
|
||||
context
|
||||
context,
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = onError,
|
||||
context
|
||||
context,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -5,8 +25,9 @@ import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
@@ -14,13 +35,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 +60,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 +100,32 @@ class RegisterAccounts(
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val body = jsonObject.toRequestBody(mediaType)
|
||||
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url("https://push.amethyst.social/register")
|
||||
.post(body)
|
||||
.build()
|
||||
val request =
|
||||
Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url("https://push.amethyst.social/register")
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
val client = HttpClient.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
|
||||
val isSucess = client.newCall(request).execute().use {
|
||||
it.isSuccessful
|
||||
}
|
||||
val isSucess = client.newCall(request).execute().use { it.isSuccessful }
|
||||
} catch (e: java.lang.Exception) {
|
||||
val tag = if (BuildConfig.FLAVOR == "play") {
|
||||
"FirebaseMsgService"
|
||||
} else {
|
||||
"UnifiedPushService"
|
||||
}
|
||||
if (e is CancellationException) throw e
|
||||
val tag =
|
||||
if (BuildConfig.FLAVOR == "play") {
|
||||
"FirebaseMsgService"
|
||||
} else {
|
||||
"UnifiedPushService"
|
||||
}
|
||||
Log.e(tag, "Unable to register with push server", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun go(notificationToken: String) = withContext(Dispatchers.IO) {
|
||||
signEventsToProveControlOfAccounts(accounts, notificationToken) {
|
||||
postRegistrationEvent(it)
|
||||
}
|
||||
suspend fun go(notificationToken: String) =
|
||||
withContext(Dispatchers.IO) {
|
||||
signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) }
|
||||
|
||||
PushNotificationUtils.hasInit = true
|
||||
}
|
||||
PushNotificationUtils.hasInit = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,21 +1,45 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
object PlaybackClientController {
|
||||
val cache = LruCache<Int, SessionToken>(1)
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
fun prepareController(
|
||||
controllerID: String,
|
||||
videoUri: String,
|
||||
callbackUri: String?,
|
||||
context: Context,
|
||||
onReady: (MediaController) -> Unit
|
||||
onReady: (MediaController) -> Unit,
|
||||
) {
|
||||
try {
|
||||
// creating a bundle object
|
||||
@@ -25,23 +49,28 @@ object PlaybackClientController {
|
||||
bundle.putString("uri", videoUri)
|
||||
bundle.putString("callbackUri", callbackUri)
|
||||
|
||||
val sessionTokenLocal = SessionToken(context, ComponentName(context, PlaybackService::class.java))
|
||||
val controllerFuture = MediaController
|
||||
.Builder(context, sessionTokenLocal)
|
||||
.setConnectionHints(bundle)
|
||||
.buildAsync()
|
||||
var session = cache.get(context.hashCode())
|
||||
if (session == null) {
|
||||
session = SessionToken(context, ComponentName(context, PlaybackService::class.java))
|
||||
cache.put(context.hashCode(), session)
|
||||
}
|
||||
|
||||
val controllerFuture =
|
||||
MediaController.Builder(context, session).setConnectionHints(bundle).buildAsync()
|
||||
|
||||
controllerFuture.addListener(
|
||||
{
|
||||
try {
|
||||
onReady(controllerFuture.get())
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
|
||||
}
|
||||
},
|
||||
MoreExecutors.directExecutor()
|
||||
MoreExecutors.directExecutor(),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -11,7 +31,7 @@ import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
|
||||
@UnstableApi // Extend MediaSessionService
|
||||
class PlaybackService : MediaSessionService() {
|
||||
@@ -22,17 +42,19 @@ class PlaybackService : MediaSessionService() {
|
||||
private var managerLocal: MultiPlayerPlaybackManager? = null
|
||||
|
||||
fun newHslDataSource(): MediaSource.Factory {
|
||||
return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClient.getHttpClient()))
|
||||
return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClientManager.getHttpClient()))
|
||||
}
|
||||
|
||||
fun newProgressiveDataSource(): MediaSource.Factory {
|
||||
return ProgressiveMediaSource.Factory(
|
||||
(applicationContext as Amethyst).videoCache.get(HttpClient.getHttpClient())
|
||||
(applicationContext as Amethyst).videoCache.get(HttpClientManager.getHttpClient()),
|
||||
)
|
||||
}
|
||||
|
||||
fun lazyHlsDS(): MultiPlayerPlaybackManager {
|
||||
managerHls?.let { return it }
|
||||
managerHls?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val newInstance = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
|
||||
managerHls = newInstance
|
||||
@@ -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
|
||||
@@ -63,7 +90,7 @@ class PlaybackService : MediaSessionService() {
|
||||
Log.d("Lifetime Event", "PlaybackService.onCreate")
|
||||
|
||||
// Stop all videos and recreates all managers when the proxy changes.
|
||||
HttpClient.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
|
||||
HttpClientManager.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
|
||||
}
|
||||
|
||||
private fun onProxyUpdated() {
|
||||
@@ -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()
|
||||
@@ -86,7 +114,7 @@ class PlaybackService : MediaSessionService() {
|
||||
override fun onDestroy() {
|
||||
Log.d("Lifetime Event", "PlaybackService.onDestroy")
|
||||
|
||||
HttpClient.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
|
||||
HttpClientManager.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
|
||||
|
||||
managerHls?.releaseAppPlayers()
|
||||
managerLocal?.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.previews
|
||||
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
|
||||
private val imageExtensionArray = arrayOf(".gif", ".png", ".jpg", ".jpeg", ".bmp", ".webp")
|
||||
|
||||
suspend fun fetchUrlPreview(timeOut: Int = 30000) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
fetch(timeOut)
|
||||
} catch (t: Throwable) {
|
||||
callback?.onFailed(t)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetch(timeOut: Int = 30000) {
|
||||
lateinit var urlInfoItem: UrlInfoItem
|
||||
if (checkIsImageUrl()) {
|
||||
urlInfoItem = UrlInfoItem(url = url, image = url)
|
||||
} else {
|
||||
val document = getDocument(url, timeOut)
|
||||
urlInfoItem = parseHtml(url, document)
|
||||
}
|
||||
callback?.onComplete(urlInfoItem)
|
||||
}
|
||||
|
||||
private fun checkIsImageUrl(): Boolean {
|
||||
val uri = Uri.parse(url)
|
||||
var isImage = false
|
||||
for (imageExtension in imageExtensionArray) {
|
||||
if (uri.path != null && uri.path!!.toLowerCase(Locale.getDefault()).endsWith(imageExtension)) {
|
||||
isImage = true
|
||||
break
|
||||
suspend fun fetchUrlPreview(timeOut: Int = 30000) =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
fetch(timeOut)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
callback?.onFailed(t)
|
||||
}
|
||||
}
|
||||
return isImage
|
||||
|
||||
private suspend fun fetch(timeOut: Int = 30000) {
|
||||
callback?.onComplete(getDocument(url, timeOut))
|
||||
}
|
||||
|
||||
fun cleanUp() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
/**
|
||||
* 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 com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
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 +35,94 @@ 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 {
|
||||
HttpClientManager.getHttpClient().newCall(request).execute().use {
|
||||
checkNotInMainThread()
|
||||
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 +132,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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesGlobalChats),
|
||||
// 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 }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user